From 931505a9cc4a6ab6e877f5a85a2dd05437e01da5 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 18 Sep 2026 04:09:03 +0800 Subject: [PATCH 001/253] fix(workspace-files): list directory links through their resolved target Windows directory junctions are reparse points that lstat reports as symlinks, so list refused My Music, My Pictures and My Videos with workspace-file/not-directory while its own listing reported them as directories. list now follows a final link, requires the resolved target to be a directory inside the workspace root, and keeps read's no-follow gate. --- ...26-09-05-workspace-files-service.i18n.yaml | 4 +-- .../2026-09-05-workspace-files-service.md | 8 ++--- .../2026-09-05-workspace-files-service.zh.md | 8 ++--- ...ndows-directory-junction-listing.i18n.yaml | 6 ++++ ...9-18-windows-directory-junction-listing.md | 27 +++++++++++++++++ ...8-windows-directory-junction-listing.zh.md | 27 +++++++++++++++++ packages/api/workspace-files/README.i18n.yaml | 4 +-- packages/api/workspace-files/README.md | 2 +- packages/api/workspace-files/README.zh.md | 2 +- packages/api/workspace-files/src/index.ts | 11 ++++++- .../api/workspace-files/tests/list.spec.ts | 29 ++++++++++++++++--- 11 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 050ce7d48f..8b3394223d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563 -2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715 +2026-09-05-workspace-files-service.md: b8301a423c0c9cf69a177f079de38dff0b112cd4 +2026-09-05-workspace-files-service.zh.md: c01324db5aa3dc0112604f297dcc4782d42347a4 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 40157508a8..b8301a423c 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -43,7 +43,7 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. - **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. - **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. -- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. +- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`. A child the listing reports as a directory opens through its resolved target, including a Windows junction, and the listing then names that target; [directory link listing](../bug-fix/2026-09-18-windows-directory-junction-listing.md) owns the rule. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire @@ -56,12 +56,12 @@ Two path vocabularies leave the service, and each method uses exactly one. `read `read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: -1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. +1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods. `list` instead follows a final link and judges the resolved target: the target must stat as a directory, and a link to a file or a link whose target is gone is `not-directory` (kind `symlink`). An empty path is a `gateway/bad-request`. 2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. 3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`. 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected. -After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position. +After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position; a final link resolves and passes containment before its target kind is read. ### Failures @@ -142,7 +142,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, ## Testing -Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, directory links, links whose target is gone, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 51de0ad5b6..c01324db5a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -43,7 +43,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 - **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 - **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 -- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 +- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`。列表报告为目录的子项经其解析目标打开,Windows 目录联接亦然,此时列表以该目标命名;[目录链接列举](../bug-fix/2026-09-18-windows-directory-junction-listing.zh.md)拥有该规则。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 @@ -56,12 +56,12 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: -1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 +1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`)。`list` 改为跟随末端链接并判定解析后的目标:目标经 stat 必须为目录,指向文件的链接或目标已消失的链接为 `not-directory`(kind 为 `symlink`)。空路径是 `gateway/bad-request`。 2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。 -路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。 +路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置;末端链接先解析并通过包含检查,再读取其目标种类。 ### 失败 @@ -142,7 +142,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、目录链接、目标已消失的链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml new file mode 100644 index 0000000000..fb7a54a51e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md +2026-09-18-windows-directory-junction-listing.md: 8e0edc0b997c1b5526395de97d380daa7bc86e48 +2026-09-18-windows-directory-junction-listing.zh.md: 3236c063d667add26f400506c775424f095b699a diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md new file mode 100644 index 0000000000..8e0edc0b99 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md @@ -0,0 +1,27 @@ +# Agent Note: List directory links in the workspace file tree + +Status: implemented + +English | [中文](2026-09-18-windows-directory-junction-listing.zh.md) + +## Problem + +Windows profile directories such as `Documents\My Music` are directory junctions: reparse points that `lstat` reports as symlinks rather than directories. The `list` endpoint gated on `lstat` before resolving, so it refused every final link with `workspace-file/not-directory`, while its own listing reported those same children as `directory` because `listDir` resolves each child. The file tree therefore listed the junctions and refused to open them, and the Sidebar reported `My Music`, `My Pictures`, and `My Videos` as not a directory. + +## Decision + +`list` follows a final link and judges what it resolves to. The resolved target must pass workspace containment and must stat as a directory, so a directory link inside the workspace lists through its target, and the returned workspace path names that target. A link resolving outside the workspace is `workspace-file/outside-workspace`; a link to a non-directory, or one whose target is gone, is `workspace-file/not-directory` with kind `symlink`. + +`read`, `readBytes`, `readAll`, `readRelated`, and `stat` keep their `lstat` gate that refuses a final symlink, so no read path follows a link. The [Workspace Files service](../architecture/2026-09-05-workspace-files-service.md) keeps ownership of the service; its earlier rule that `list` rejects a final symlink is superseded here. + +## Alternatives considered + +**Drop the `lstat` gate for every method.** Rejected: reading through a link is a separate exposure decision, and the file methods' no-follow rule is a trust rule the report asked to keep. + +**Special-case Windows junctions.** Rejected: `fs-local` already reports a junction as a `symlink` path entry, and the resolve-then-contain rule is identical for a junction and a POSIX directory symlink. + +**Keep the gate and report a linked directory from the parent listing only.** Rejected: the listing already reports the resolved type, so the endpoint contradicted itself and a reader could not open what the parent presented as a directory. + +## Consequences + +A directory link inside the workspace now lists like its target, and the Windows profile junctions expand in the Sidebar. A link resolving outside the workspace reports `outside-workspace` instead of `not-directory`, which names the actual reason. Read access is unchanged. Tests cover a directory link inside the workspace, a link resolving outside it, and a link whose target is deleted; the spec creates a junction with `symlink(target, path, 'junction')` on Windows and a directory symlink elsewhere, so one spec runs on both. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md new file mode 100644 index 0000000000..3236c063d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 列举工作区文件树中的目录链接 + +Status: implemented + +[English](2026-09-18-windows-directory-junction-listing.md) | 中文 + +## Problem + +Windows 的个人目录如 `Documents\My Music` 是目录联接:`lstat` 把这类重解析点报告为符号链接而不是目录。`list` 端点在解析之前先用 `lstat` 把关,于是把所有末端链接都以 `workspace-file/not-directory` 拒绝;而它自己的列举又把同样的子项报告为 `directory`,因为 `listDir` 会解析每个子项。文件树因此既列出这些联接、又拒绝打开它们,侧栏把 `My Music`、`My Pictures`、`My Videos` 报告为非目录。 + +## Decision + +`list` 跟随末端链接并判定其解析结果。解析后的目标必须通过工作区包含检查,且经 stat 必须为目录,因此工作区内的目录链接经其目标列举,返回的工作区路径也以该目标命名。解析到工作区外的链接为 `workspace-file/outside-workspace`;指向非目录的链接、或目标已消失的链接,为 kind 为 `symlink` 的 `workspace-file/not-directory`。 + +`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 保留拒绝末端符号链接的 `lstat` 把关,因此没有任何读取路径跟随链接。[工作区文件服务](../architecture/2026-09-05-workspace-files-service.zh.md)继续拥有该服务;它此前「`list` 拒绝末端符号链接」的规则由本记录取代。 + +## Alternatives considered + +**对所有方法去掉 `lstat` 把关。** 被否:经链接读取是另一项暴露决策,文件方法的「不跟随」规则是报告方要求保留的信任规则。 + +**单独处理 Windows 目录联接。** 被否:`fs-local` 已把目录联接报告为 `symlink` 路径项,且「先解析再判包含」的规则对目录联接与 POSIX 目录符号链接完全相同。 + +**保留把关,只靠父层列举报告被链接的目录。** 被否:列举本来就报告解析后的类型,端点因此自相矛盾,读取方无法打开父层呈现为目录的条目。 + +## Consequences + +工作区内的目录链接现在与其目标一样可列举,Windows 个人目录中的联接可在侧栏展开。解析到工作区外的链接报告 `outside-workspace` 而不是 `not-directory`,如实说明原因。读取权限不变。测试覆盖工作区内的目录链接、解析到工作区外的链接,以及目标被删除的链接;spec 在 Windows 上用 `symlink(target, path, 'junction')` 创建目录联接,在其他平台创建目录符号链接,因此同一 spec 两端都跑。 diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 4bbf6a7a48..c43627f94d 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: f7e40138d8b790f993e3cc8a15d4216ca966cabb -README.zh.md: 04548bed7ffc1698e9a05ffbc93157ea07d6f932 +README.md: b4490d385f3a34251920534bb57e6d84deeda729 +README.zh.md: 1c11e14b1a5bec54d446f367474e14720a3a478f diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f7e40138d8..b4490d385f 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -51,7 +51,7 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and ### File-read and directory checks -Every operation first uses `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. `list` alone requires the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`. +Every operation first uses `lstat` to reject a missing path or the wrong file kind; the file-reading operations also refuse a final symlink, even one pointing back inside the workspace. `list` instead follows a final link — a directory symlink on any platform, including a Windows junction — and requires what it resolves to be a directory inside the workspace root, so a linked directory lists like its target. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`. ### The change feed diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 04548bed7f..1c11e14b1a 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 文件读取与目录检查 -每项操作都先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。只有 `list` 要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 +每项操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接(任何平台上的目录符号链接,包括 Windows 目录联接),要求解析结果是工作区内的目录,因此被链接的目录与其目标一样可列举。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 ### 变更流 diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 64e91c7fbf..2c4f6f31b1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -335,7 +335,10 @@ export class WorkspaceFiles extends TypertRemoteService { @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal) - if (entry.type !== 'directory') { + // A final link — a Windows junction or a symlink — is listed through the + // directory it resolves to, matching the child type `listDir` reports for + // that entry; `read` keeps its own no-follow gate on the final component. + if (entry.type !== 'directory' && entry.type !== 'symlink') { throw new RemoteError( 'workspace-file/not-directory', `"${path}" is a ${entry.type}`, @@ -343,6 +346,12 @@ export class WorkspaceFiles extends TypertRemoteService { ) } const target = await this.confine(root, workspaceRoot, path, signal) + if (entry.type === 'symlink') { + const info = await this.ctx.fs.stat(target, signal) + if (info?.type !== 'directory') { + throw new RemoteError('workspace-file/not-directory', `"${path}" does not resolve to a directory`, { path, kind: 'symlink' }) + } + } const children = await this.ctx.fs.listDir(target, signal) return { path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)), diff --git a/packages/api/workspace-files/tests/list.spec.ts b/packages/api/workspace-files/tests/list.spec.ts index 6cadad6ce3..e2e603dfe4 100644 --- a/packages/api/workspace-files/tests/list.spec.ts +++ b/packages/api/workspace-files/tests/list.spec.ts @@ -1,6 +1,6 @@ /** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' @@ -20,6 +20,10 @@ afterEach(async () => { const endpoint = (caps?: { maxEntries?: number }): ReturnType => harness.endpoint(caps) +/** Create a link to a directory: a Windows junction where junctions exist, a directory symlink elsewhere. */ +const directoryLink = (target: string, link: string): Promise => + symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir') + describe('workspaceFiles.list — the happy path', () => { it('lists the root as the empty workspace path, with types and file sizes', async () => { await mkdir(join(workspace, 'src')) @@ -64,6 +68,15 @@ describe('workspaceFiles.list — the happy path', () => { { name: 'to-file', type: 'file', size: 1 }, ]) }) + + it('lists a directory link whose target is inside the workspace', async () => { + await mkdir(join(workspace, 'real-dir')) + await writeFile(join(workspace, 'real-dir', 'child.txt'), 'x', 'utf8') + await directoryLink(join(workspace, 'real-dir'), join(workspace, 'dir-link')) + const listing = await endpoint().list(harness.scope, 'dir-link', signal()) + expect(listing.path).toBe('real-dir') + expect(listing.entries).toEqual([{ name: 'child.txt', type: 'file', size: 1 }]) + }) }) describe('workspaceFiles.list — the entry cap', () => { @@ -93,11 +106,19 @@ describe('workspaceFiles.list — gates', () => { expect(failure.code).toBe('workspace-file/outside-workspace') }) - it('rejects a symlinked directory before following it, wherever it points', async () => { - await symlink(outside, join(workspace, 'escape')) + it('rejects a directory link that resolves outside the workspace', async () => { + await directoryLink(outside, join(workspace, 'escape')) const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a directory link whose target is gone', async () => { + await mkdir(join(workspace, 'vanishing')) + await directoryLink(join(workspace, 'vanishing'), join(workspace, 'dangling')) + await rm(join(workspace, 'vanishing'), { recursive: true }) + const failure = await failureOf(endpoint().list(harness.scope, 'dangling', signal())) expect(failure.code).toBe('workspace-file/not-directory') - expect(failure.details).toMatchObject({ kind: 'symlink' }) + expect(failure.details).toMatchObject({ path: 'dangling', kind: 'symlink' }) }) it('rejects a file, which has no children to list', async () => { From 3515c48a37cadae13e49a518fc9b28b474416bc3 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:37:38 +0800 Subject: [PATCH 002/253] fix(desktop): resolve the development runtime directory from the build target The unpackaged shell derived the primary-runtime directory from process.arch, while preparePrimaryRuntime() resolves the target through resolveDesktopBuildTarget(), which fixes Windows to x64. On a Windows ARM64 host the shell looked under targets/win-arm64 while the payload was under targets/win-x64, and the missing office-skills/scripts/check_office.py became a fatal startup dialog. The launcher now resolves that directory through the same resolver and passes it to the shell as DSH_DESKTOP_PRIMARY_RUNTIME_DIR. --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/desktop-build-paths.d.mts | 13 +++++++++++++ apps/desktop/scripts/desktop-build-paths.mjs | 17 +++++++++++++++++ apps/desktop/scripts/dev.ts | 2 ++ apps/desktop/scripts/test-workspace-updates.ts | 2 +- apps/desktop/src/main.ts | 10 +++++++++- apps/desktop/tests/desktop-build-paths.spec.ts | 10 ++++++++++ apps/desktop/tests/main-startup.spec.ts | 1 + 10 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..02b287a60c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 860867eef3ae1da8c89d8292f00afa9ad2a20872 +README.zh.md: ac9a4ddbfff1ad96950fbda6e14ea2632480ea03 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..860867eef3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -342,7 +342,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha ## Low-level development overrides -An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` select explicit runtime resources. The development launcher sets `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` to the primary-runtime directory of the target it prepared, because the Windows target is always x64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..ac9a4ddbff 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -342,7 +342,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 底层开发覆盖项 -未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 用于选择明确的运行时资源。开发启动器将 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 设置为其已准备目标的 primary-runtime 目录,因为 Windows 目标固定为 x64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index f20d60da48..2939a5b2e6 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -47,3 +47,16 @@ export function resolveDesktopTargetBuildPaths( hostPlatform?: NodeJS.Platform, hostArch?: string, ): DesktopTargetBuildPaths + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 375e61b542..0d1abaefb4 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -69,3 +69,20 @@ export function resolveDesktopTargetBuildPaths( ) { return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) } + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * The build target fixes Windows to x64, so the shell cannot derive this directory from + * the architecture of the process that launched it. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {string} Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 05e1bdbf0e..481ce59f4b 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -7,6 +7,7 @@ import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import type { DesktopRelease } from '../src/release.ts' +import { developmentRuntimeDirectory } from './desktop-build-paths.mjs' import { prepareDevelopmentProject } from './development-project.ts' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' @@ -66,6 +67,7 @@ async function launchElectron(): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index b768de5544..81c6c96921 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -56,7 +56,7 @@ try { ...(interactive ? ['--interactive'] : [])], { cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root, TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(), - DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: join(application, 'runtime', 'primary-runtime'), DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks. stdio: 'inherit', windowsHide: false, }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..925458c4d0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -98,6 +98,14 @@ function runtimeResources(): RuntimeResources { return { node, nodeBin, pnpm, dsh } } +function developmentPrimaryRuntime(): string { + const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR + if (directory === undefined || directory === '') { + throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch') + } + return directory +} + function developmentHostInspectPort(enabled: boolean): number | undefined { const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT if (!enabled || configured === undefined || configured === '') return undefined @@ -261,7 +269,7 @@ async function main(): Promise { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, hostInspectPort, process.env, onFailure, - development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime') + development ? developmentPrimaryRuntime() : join(process.resourcesPath, 'runtime', 'primary-runtime'), resources) return { diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index fdbab81b60..53767b270a 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -2,6 +2,7 @@ import { join, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { desktopTargetBuildPaths, + developmentRuntimeDirectory, resolveDesktopBuildTarget, } from '../scripts/desktop-build-paths.mjs' @@ -38,6 +39,15 @@ describe('desktop build paths', () => { expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) }) + it('resolves the development primary runtime from the build target rather than the host architecture', () => { + expect(developmentRuntimeDirectory({}, 'darwin', 'arm64')) + .toContain(join('targets', 'mac-arm64', 'runtime', 'primary-runtime')) + expect(developmentRuntimeDirectory({}, 'darwin', 'x64')) + .toContain(join('targets', 'mac-x64', 'runtime', 'primary-runtime')) + expect(developmentRuntimeDirectory({}, 'win32', 'arm64')) + .toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime')) + }) + it('resolves environment overrides and rejects unsupported targets', () => { expect(resolveDesktopBuildTarget({ DSH_DESKTOP_TARGET_PLATFORM: 'darwin', diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..da680befcf 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -291,6 +291,7 @@ beforeEach(() => { vi.spyOn(console, 'info').mockImplementation(() => {}) vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') + vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime') vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) From 08b310b5a8e70300150671b54aa95e650ffa24ac Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:58:49 +0800 Subject: [PATCH 003/253] fix(client): paint every keyboard focus ring with the brand colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus rings named eight different colours across 54 client stylesheets, and a control that declared no ring fell back to the browser default, whose colour follows the operating-system accent — so the same build showed orange on one machine and blue on another. Every keyboard focus ring now uses --dsw-alias-brand-primary. A global ui-theme focus.css sheet reaches the controls that declare no ring of their own, and 49 declarations name the same token. Only the colour changed: each declaration keeps its width, style and offset. --- ...6-09-20-focus-ring-brand-primary.i18n.yaml | 6 ++++ .../2026-09-20-focus-ring-brand-primary.md | 33 +++++++++++++++++++ .../2026-09-20-focus-ring-brand-primary.zh.md | 33 +++++++++++++++++++ .../ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 2 +- .../src/client/chat/TurnNavigator.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 +-- .../src/client/ChangedFiles.module.css | 2 +- .../src/client/FeedbackDialog.module.css | 4 +-- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PermissionSelect.module.css | 2 +- .../src/client/PlanModeControl.module.css | 2 +- .../ui-plan/src/client/PlanPreview.module.css | 4 +-- .../src/ConnectionIndicator.module.css | 2 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/JsonTree.module.css | 4 +-- .../src/RiskConfirmation.module.css | 2 +- .../src/markdown/MarkdownText.module.css | 4 +-- .../src/client/ModelsSection.module.css | 2 +- .../PluginInventorySettingsTab.module.css | 6 ++-- .../client/PluginsSettingsSection.module.css | 2 +- .../src/client/view/Browser.module.css | 2 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/src/client/styles.ts | 2 ++ packages/client/ui-theme/src/styles/focus.css | 8 +++++ .../tests/client-styles.client.spec.ts | 1 + .../tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 26 +++++++-------- .../src/client/TrajectoryTimeline.module.css | 4 +-- .../src/client/TrajectoryToolbar.module.css | 6 ++-- .../src/client/WorkflowRunPanel.module.css | 6 ++-- 31 files changed, 132 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md create mode 100644 packages/client/ui-theme/src/styles/focus.css diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml new file mode 100644 index 0000000000..6707b51680 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e +2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md new file mode 100644 index 0000000000..3c6c041ac0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -0,0 +1,33 @@ +# Agent Note: One keyboard-focus colour for every control + +Status: implemented + +English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) + +## Problem + +Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. + +## Decision + +Every keyboard focus ring uses `--dsw-alias-brand-primary`. + +Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. + +The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. + +## Alternatives considered + +**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. + +**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. + +**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. + +**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. + +## Consequences + +The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. + +`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md new file mode 100644 index 0000000000..541537c54e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -0,0 +1,33 @@ +# Agent Note:所有控件共用一个键盘焦点颜色 + +Status: implemented + +[English](2026-09-20-focus-ring-brand-primary.md) | 中文 + +## 问题 + +客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 + +## 决策 + +所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 + +实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 + +全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 + +## 考虑过的其他做法 + +**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 + +**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 + +**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 + +**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 + +## 影响 + +焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 + +`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index f551605a4a..127856cf99 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -102,6 +102,6 @@ .frame:focus-visible, .error:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index a8ccc480a8..1fb36599ce 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -231,7 +231,7 @@ } .retrySummary:focus-visible { - outline: 1.5px solid var(--dsw-alias-button-info-fill); + outline: 1.5px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 0352ff42d6..b01654cab0 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -139,7 +139,7 @@ inset: 0 0 0 auto; width: 20px; border-radius: inherit; - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; content: ''; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 516ba7e83a..1853f80ac9 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -77,7 +77,7 @@ } .header:focus-visible { - outline: 2px solid var(--dsw-alias-label-tertiary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -258,7 +258,7 @@ } .action:focus-visible { - outline: 2px solid var(--dsw-alias-label-tertiary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 536bed4f6d..57d19d4130 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -21,7 +21,7 @@ button.header:disabled { cursor: progress; } .list { margin: 0; padding: 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l2); } .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; min-height: 24px; margin: 0; padding: 7px 18px 7px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 11px; line-height: 18px; text-align: left; } .row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } -.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); } +.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } .row:disabled { cursor: progress; } .path { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index af2cacebe7..ee2cc6ee16 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -39,7 +39,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-button-primary-fill); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -79,7 +79,7 @@ .detail:focus { outline: none; border-color: var(--dsw-alias-border-l3); - box-shadow: 0 0 0 1px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); } .submit { diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 799c10776c..c3a406d72e 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -34,7 +34,7 @@ } .trigger:focus-visible { - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } .trigger:disabled { diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css index 32cc84dd14..00b48a345b 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css @@ -23,7 +23,7 @@ } .trigger:focus-visible { - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } .trigger:disabled { diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 793ca4e5d6..43664921b4 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -34,7 +34,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index d63529070b..037803f7f2 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -22,7 +22,7 @@ } :global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); } .card:hover { background: var(--plan-card-hover); } -.card:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.card:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } @@ -30,7 +30,7 @@ .cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } .reviewLink:hover { color: var(--dsw-alias-label-primary); } -.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } .message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index e559fbca0e..d65fcc82d2 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -56,7 +56,7 @@ } .warning:focus-visible { - outline: 2px solid var(--dsw-alias-state-warn-label); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d425ca85c..c4164cd18f 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -27,7 +27,7 @@ } .copyable:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 9abbc23bdc..f348c5c82c 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -187,7 +187,7 @@ } .stringToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -286,7 +286,7 @@ } .actionButton:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index ae45ba9e00..fcba264b38 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -56,7 +56,7 @@ } .acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index db318ac6eb..edef6040ae 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -83,7 +83,7 @@ .markdown a:focus-visible, .fileMention:focus-visible { - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Leading link-category glyph inside anchors, file mentions, and @@ -220,7 +220,7 @@ the sheet's link focus color. */ .tableScroll:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Many-column tables (the renderer's `md-table-wide` hook) keep their diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 15b8933fa3..071b2523c5 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -216,7 +216,7 @@ .iconButton:focus-visible, .customizedSummary:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Editing surface: a filled module on the panel, matching the settings diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index bbf984fb20..510120d2e1 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -151,7 +151,7 @@ } .cardContent:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -239,7 +239,7 @@ } .groupToggle:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -297,7 +297,7 @@ } .switcher:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index bd6006ea56..c0f617575d 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -58,7 +58,7 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 2px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css index 0914d8e6f5..fa1340f2d3 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css +++ b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css @@ -108,7 +108,7 @@ } .address:focus { - outline: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 144070ba3f..c99c729fbb 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -493,7 +493,7 @@ } .panelRow:focus-visible { - outline: 2px solid var(--dsw-alias-label-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-theme/src/client/styles.ts b/packages/client/ui-theme/src/client/styles.ts index 2f7c97fd4b..4da44d9696 100644 --- a/packages/client/ui-theme/src/client/styles.ts +++ b/packages/client/ui-theme/src/client/styles.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import base from '../styles/base.css?inline' import cornerShape from '../styles/corner-shape.css?inline' import designPlatform from '../styles/design-platform.css?inline' +import focus from '../styles/focus.css?inline' import scrollbar from '../styles/scrollbar.css?inline' import gradientShadowText from '../styles/gradient-shadow-text.css?inline' import shiki from '../styles/shiki.css?inline' @@ -12,6 +13,7 @@ const STYLES = [ ['base.css', base], ['corner-shape.css', cornerShape], ['design-platform.css', designPlatform], + ['focus.css', focus], ['scrollbar.css', scrollbar], ['gradient-shadow-text.css', gradientShadowText], ['shiki.css', shiki], diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css new file mode 100644 index 0000000000..6837e76b1b --- /dev/null +++ b/packages/client/ui-theme/src/styles/focus.css @@ -0,0 +1,8 @@ +/* One keyboard-focus colour for every control. A control that declares its own + * focus ring sets the colour explicitly, so this sheet reaches only the ones + * that would otherwise paint the browser default — whose colour follows the + * operating-system accent and therefore differs per machine. Declaring + * `outline-color` alone preserves each control's own width, style and offset. */ +:focus-visible { + outline-color: var(--dsw-alias-brand-primary); +} diff --git a/packages/client/ui-theme/tests/client-styles.client.spec.ts b/packages/client/ui-theme/tests/client-styles.client.spec.ts index 9e5cb8a7bd..162d6d042f 100644 --- a/packages/client/ui-theme/tests/client-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/client-styles.client.spec.ts @@ -23,6 +23,7 @@ describe('ui-theme client styles', () => { `${PLUGIN_ID}/base.css`, `${PLUGIN_ID}/corner-shape.css`, `${PLUGIN_ID}/design-platform.css`, + `${PLUGIN_ID}/focus.css`, `${PLUGIN_ID}/scrollbar.css`, `${PLUGIN_ID}/gradient-shadow-text.css`, `${PLUGIN_ID}/shiki.css`, diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index 824bd18fde..d5442160be 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -255,4 +255,4 @@ } .path:hover { text-decoration: underline; } -.path:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; } +.path:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index b07fbe48a2..594c0b3570 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -75,7 +75,7 @@ } .historyLoadButton:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -206,7 +206,7 @@ } .table tbody tr:not([data-collapsed-summary]):focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary); } .table tbody tr[data-selected='true'] { @@ -282,7 +282,7 @@ var(--dsw-alias-brand-primary-new-colorprimary-new-color) 18%, var(--dsw-alias-bg-layer-1) ); - box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color); + box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary); } .requestBoundaryControl[data-request-status='error']::before, @@ -734,7 +734,7 @@ } .table tbody tr[data-collapsed-summary]:focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary); } .collapsedTurnContent { @@ -1001,7 +1001,7 @@ .close:focus-visible, .detailTab:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1089,7 +1089,7 @@ .overviewHierarchyNavLink:focus-visible { color: var(--dsw-alias-label-primary); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1147,7 +1147,7 @@ } .timestampToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1215,7 +1215,7 @@ } .overviewTitle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1316,7 +1316,7 @@ } .programAction:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1521,7 +1521,7 @@ } .thinkingToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1763,7 +1763,7 @@ } .attachmentDisclosure > summary:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1810,7 +1810,7 @@ .assistantToolCallButton:focus-visible { background: var(--dsw-alias-interactive-bg-hover); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1884,7 +1884,7 @@ .toolCatalogSummary:focus-visible { background: var(--dsw-alias-interactive-bg-hover); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index a10fffb9d1..f78dc63472 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -98,7 +98,7 @@ } .earlierHistory:focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary); } .empty { @@ -111,7 +111,7 @@ } .track:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index c286665666..ad7f485f08 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -52,7 +52,7 @@ } .toggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -95,7 +95,7 @@ } .control:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -156,7 +156,7 @@ } .action:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 190f136f70..c7875b946c 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -17,7 +17,7 @@ } .runHeader:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -80,7 +80,7 @@ } .phaseHeader:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; border-radius: 4px; } @@ -210,7 +210,7 @@ } .memberButton:focus-visible .memberLabelWrap { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } From 9e7350bb8c91b763e65b868c8888f5fb17f0d651 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:05:51 +0800 Subject: [PATCH 004/253] fix(client): keep a pointer's focus silent until focus navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a control leaves it focused with :focus-visible false, so nothing is drawn. Chromium then reveals the ring the moment any key arrives — a modifier, Escape, a typed character — which is the unexplained focus ring users report: they never navigated there, yet a ring appears under the pointer's last target. Record the input modality on the document element, publish the keyboard modality only for focus navigation (Tab, arrow keys), and let the global focus sheet keep a pointer's focus transparent. Tooltip's pointer modality moves into the same module so tooltips and rings follow one rule. --- packages/client/ui-primitives/src/Tooltip.tsx | 15 +------ .../ui-primitives/src/input-modality.ts | 42 +++++++++++++++++++ .../tests/input-modality.client.spec.ts | 30 +++++++++++++ packages/client/ui-theme/src/styles/focus.css | 9 ++++ 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 packages/client/ui-primitives/src/input-modality.ts create mode 100644 packages/client/ui-primitives/tests/input-modality.client.spec.ts diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 688a411fb9..56e96e3894 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -4,6 +4,7 @@ import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' +import { pointerModality } from './input-modality.ts' /** Bubble placement relative to the anchor. */ export type TooltipSide = 'right' | 'bottom' | 'top' @@ -27,18 +28,6 @@ interface AnchorProps { type TooltipLabel = string | (() => string) -// Focus alone cannot reveal how it arrived: a closing menu hands focus back to -// its trigger, and after a mouse selection that programmatic return must not -// raise the trigger's bubble, while keyboard focus must. Capture-phase window -// listeners record the last input modality for every Tooltip. The guard keeps -// the module loadable where no window exists (node-side imports of the -// package's pure helpers). -let pointerModality = false -if (typeof window !== 'undefined') { - window.addEventListener('pointerdown', () => { pointerModality = true }, true) - window.addEventListener('keydown', () => { pointerModality = false }, true) -} - /** * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. @@ -194,7 +183,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, // what the anchor now does (pin → unpin), and the click leaves the // anchor focused, which would otherwise pin the relabelled bubble up. onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() }, - onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality) return; triggers.current.focus = true; cancelShow(); show() }, + onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {visible && !suppressed && ( diff --git a/packages/client/ui-primitives/src/input-modality.ts b/packages/client/ui-primitives/src/input-modality.ts new file mode 100644 index 0000000000..039c2c2319 --- /dev/null +++ b/packages/client/ui-primitives/src/input-modality.ts @@ -0,0 +1,42 @@ +/** + * Last-input modality for the whole document. Focus alone cannot reveal how it + * arrived: a closing menu hands focus back to its trigger, and after a mouse + * selection that programmatic return must stay silent, while keyboard focus + * must not. Capture-phase window listeners record the modality, and only focus + * navigation returns the document to the keyboard — a stray modifier or Escape + * is not a reason to reveal the pointer's focus. + * + * The guard keeps the module loadable where no window exists (node-side imports + * of the package's pure helpers). + */ + +/** Modality values published on the document element. */ +export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const + +/** Attribute carrying the current modality, read by the global focus sheet. */ +export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality' + +let pointer = false + +/** Navigation keys move focus; every other key must not reveal existing focus. */ +function isFocusNavigation(event: KeyboardEvent): boolean { + return event.key === 'Tab' || event.key.startsWith('Arrow') +} + +function publish(): void { + document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) +} + +/** Whether the last input able to own focus came from a pointer. */ +export function pointerModality(): boolean { + return pointer +} + +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', () => { pointer = true; publish() }, true) + window.addEventListener('keydown', (event) => { + if (!isFocusNavigation(event)) return + pointer = false + publish() + }, true) +} diff --git a/packages/client/ui-primitives/tests/input-modality.client.spec.ts b/packages/client/ui-primitives/tests/input-modality.client.spec.ts new file mode 100644 index 0000000000..a36d818cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/input-modality.client.spec.ts @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +/** The document modality that decides whether focus may reveal itself. */ +import { describe, expect, it } from 'vitest' +import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts' + +const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) } +const click = (): void => { window.dispatchEvent(new Event('pointerdown')) } + +describe('input modality', () => { + it('keeps a pointer focused control silent through keys that do not navigate', () => { + click() + expect(pointerModality()).toBe(true) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + + for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) + expect(pointerModality()).toBe(true) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + }) + + it('returns to the keyboard modality on focus navigation', () => { + click() + press('Tab') + expect(pointerModality()).toBe(false) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard) + + click() + press('ArrowDown') + expect(pointerModality()).toBe(false) + }) +}) diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css index 6837e76b1b..90296e5d78 100644 --- a/packages/client/ui-theme/src/styles/focus.css +++ b/packages/client/ui-theme/src/styles/focus.css @@ -6,3 +6,12 @@ :focus-visible { outline-color: var(--dsw-alias-brand-primary); } + +/* A pointer keeps its focus silent. Clicking a control leaves it focused with no + * visible ring, and a later stray key — a modifier, Escape, a typed character — + * must not be the moment that ring appears; the user never navigated there. + * Only focus navigation publishes the keyboard modality that reveals a ring + * again. Controls that own a ring keep their own colour and stay unaffected. */ +html[data-input-modality='pointer'] :focus-visible { + outline-color: transparent; +} From db9297014667d0074cdc4e71b660baf58a15f1d2 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:08:43 +0800 Subject: [PATCH 005/253] fix(client): keep focus rings inside the containers that clip them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb trail and the Miller column scroll, and the column's left edge carries no padding, so an outer ring on their buttons was clipped to a sliver. The TurnNavigator marker sits at the scroller's right edge, which clipped three of its four sides. Ring those inside the element instead — the direction issue #4655 allows — and change no layout value. --- .../ui-chat/src/client/chat/TurnNavigator.module.css | 3 ++- .../src/client/DirectoryBrowser.module.css | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index b01654cab0..29065d039d 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -140,7 +140,8 @@ width: 20px; border-radius: inherit; outline: 1px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; + /* The scroller clips the right edge; keep the marker's ring inside it. */ + outline-offset: -1px; content: ''; } diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css index 5e3bbb3775..666293d3da 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -121,6 +121,12 @@ cursor: pointer; } +.crumb:focus-visible { + /* The trail scrolls, so an outer ring would be clipped; keep it inside. */ + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .crumb:hover { color: var(--dsw-alias-label-primary); } @@ -239,6 +245,12 @@ cursor: pointer; } +.row:focus-visible { + /* The column scrolls and its left edge carries no padding; keep the ring inside. */ + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .row:hover { background: var(--dsw-alias-interactive-bg-hover); } From 9a74ba5d08a60ea8ce3cb252ecd07fc1ace55f7b Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:26:14 +0800 Subject: [PATCH 006/253] fix(client): give tooltips and focus rings the modality rule each needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooltip asks what the last input was: after any key the user is working from the keyboard, so a programmatic focus may raise a bubble. A focus ring asks something narrower — Chromium reveals an already-focused control as soon as any key arrives, including a modifier or Escape that never navigated anywhere, which is the ring users report as unexplained. Record both answers and publish the ring's narrower one, so tooltips keep their existing behaviour and rings stay silent for a pointer's focus. --- .../ui-primitives/src/input-modality.ts | 34 ++++++++++++------- .../tests/input-modality.client.spec.ts | 30 ++++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/client/ui-primitives/src/input-modality.ts b/packages/client/ui-primitives/src/input-modality.ts index 039c2c2319..8fdc4a0101 100644 --- a/packages/client/ui-primitives/src/input-modality.ts +++ b/packages/client/ui-primitives/src/input-modality.ts @@ -1,22 +1,26 @@ /** - * Last-input modality for the whole document. Focus alone cannot reveal how it - * arrived: a closing menu hands focus back to its trigger, and after a mouse - * selection that programmatic return must stay silent, while keyboard focus - * must not. Capture-phase window listeners record the modality, and only focus - * navigation returns the document to the keyboard — a stray modifier or Escape - * is not a reason to reveal the pointer's focus. + * Input modality for the whole document, under the two rules its consumers need. * - * The guard keeps the module loadable where no window exists (node-side imports - * of the package's pure helpers). + * A tooltip asks what the last input was: after any key the user is working from + * the keyboard, so a programmatic focus may raise a bubble. A focus ring asks + * something narrower — a pointer's focus must stay invisible — because Chromium + * reveals an already-focused control as soon as any key arrives, including a + * modifier or Escape that never navigated anywhere. Only focus navigation + * returns that second answer to the keyboard. + * + * Both are recorded from capture-phase window listeners. The guard keeps the + * module loadable where no window exists (node-side imports of the package's + * pure helpers). */ /** Modality values published on the document element. */ export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const -/** Attribute carrying the current modality, read by the global focus sheet. */ +/** Attribute carrying whether focus navigation last owned focus. */ export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality' let pointer = false +let pointerOwnsFocus = false /** Navigation keys move focus; every other key must not reveal existing focus. */ function isFocusNavigation(event: KeyboardEvent): boolean { @@ -24,7 +28,7 @@ function isFocusNavigation(event: KeyboardEvent): boolean { } function publish(): void { - document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) + document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointerOwnsFocus ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) } /** Whether the last input able to own focus came from a pointer. */ @@ -33,10 +37,16 @@ export function pointerModality(): boolean { } if (typeof window !== 'undefined') { - window.addEventListener('pointerdown', () => { pointer = true; publish() }, true) + window.addEventListener('pointerdown', () => { + pointer = true + pointerOwnsFocus = true + publish() + }, true) window.addEventListener('keydown', (event) => { - if (!isFocusNavigation(event)) return + // Any key returns tooltips to the keyboard; only navigation reveals a ring. pointer = false + if (!isFocusNavigation(event)) return + pointerOwnsFocus = false publish() }, true) } diff --git a/packages/client/ui-primitives/tests/input-modality.client.spec.ts b/packages/client/ui-primitives/tests/input-modality.client.spec.ts index a36d818cb4..acf032cee7 100644 --- a/packages/client/ui-primitives/tests/input-modality.client.spec.ts +++ b/packages/client/ui-primitives/tests/input-modality.client.spec.ts @@ -1,30 +1,38 @@ // @vitest-environment jsdom -/** The document modality that decides whether focus may reveal itself. */ +/** The two modality answers: what a tooltip reads, and what a focus ring reads. */ import { describe, expect, it } from 'vitest' import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts' const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) } const click = (): void => { window.dispatchEvent(new Event('pointerdown')) } +const published = (): string | null => document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE) describe('input modality', () => { - it('keeps a pointer focused control silent through keys that do not navigate', () => { + it('returns tooltips to the keyboard on any key', () => { click() expect(pointerModality()).toBe(true) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) - - for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) - expect(pointerModality()).toBe(true) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + for (const key of ['Shift', 'Escape', 'a', 'Enter']) { + click() + press(key) + expect(pointerModality()).toBe(false) + } }) - it('returns to the keyboard modality on focus navigation', () => { + it('keeps a pointer focused control silent through keys that do not navigate', () => { + click() + expect(published()).toBe(INPUT_MODALITY.pointer) + + for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) + expect(published()).toBe(INPUT_MODALITY.pointer) + }) + + it('returns the ring to the keyboard on focus navigation', () => { click() press('Tab') - expect(pointerModality()).toBe(false) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard) + expect(published()).toBe(INPUT_MODALITY.keyboard) click() press('ArrowDown') - expect(pointerModality()).toBe(false) + expect(published()).toBe(INPUT_MODALITY.keyboard) }) }) From 5a7b2e6f33fd9ff891f5ddf9da326a6a092cf5af Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 18:44:58 +0800 Subject: [PATCH 007/253] fix(client): ring the workspace header controls inside their clipping boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action cluster animates its width with overflow hidden, and the search pill clips to its rounded shape, so an outer ring on the add, search and clear controls was cut to a sliver. This package declared no focus style at all — every control fell back to the browser default inside those boxes. Ring the three controls inside the element, the direction issue #4655 allows. --- .../src/client/rows/WorkspaceBrowser.module.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 930f492b94..7340b2d7b8 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -30,6 +30,15 @@ color: var(--dsw-alias-label-secondary); } +/* The action cluster animates its width and the search pill rounds its corners, + * so both clip an outer ring; these controls keep theirs inside instead. */ +.iconButton:focus-visible, +.searchButton:focus-visible, +.clearButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } From 65ba30ab4f4e68c9d0e55da079b11e67c5a73113 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 18:47:45 +0800 Subject: [PATCH 008/253] refactor(client): name the standard focus-ring width Every ring that used 2px now reads --dsw-focus-ring-width. The dense tables and toolbars keep their 1px ring on purpose: at 2px they visibly outweigh the rows they sit between, so the standard value and the dense exception stay distinct rather than converging on a number nobody measured. --- .../src/client/AgentPresetSection.module.css | 6 +++--- .../client/ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 ++-- .../src/client/DirectoryBrowser.module.css | 4 ++-- .../src/client/FeedbackDialog.module.css | 2 +- .../ui-plan/src/client/PlanModeControl.module.css | 2 +- .../client/ui-plan/src/client/PlanPreview.module.css | 4 ++-- .../src/client/PluginManagerPage.module.css | 10 +++++----- packages/client/ui-primitives/src/Checkbox.module.css | 2 +- .../ui-primitives/src/ConnectionIndicator.module.css | 2 +- packages/client/ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/RiskConfirmation.module.css | 2 +- packages/client/ui-primitives/src/Switch.module.css | 2 +- .../src/client/PluginInventorySettingsTab.module.css | 6 +++--- .../src/client/PluginConfigForm.module.css | 2 +- .../src/client/PluginsSettingsSection.module.css | 2 +- .../ui-settings-plugins/src/client/fields.module.css | 2 +- .../ui-sidebar/src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/src/styles/focus.css | 7 +++++++ .../src/client/tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 4 ++-- .../src/client/WorkflowRunPanel.module.css | 6 +++--- .../src/client/rows/WorkspaceBrowser.module.css | 2 +- 23 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 91111b0682..2c467892a4 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -184,7 +184,7 @@ } .cardMain:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -224,7 +224,7 @@ } .cardMain:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -311,7 +311,7 @@ } .iconButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 127856cf99..0ff6257b94 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -102,6 +102,6 @@ .frame:focus-visible, .error:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 1853f80ac9..0417786ef0 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -77,7 +77,7 @@ } .header:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -258,7 +258,7 @@ } .action:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css index 666293d3da..d4ee1a8231 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -123,7 +123,7 @@ .crumb:focus-visible { /* The trail scrolls, so an outer ring would be clipped; keep it inside. */ - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -247,7 +247,7 @@ .row:focus-visible { /* The column scrolls and its left edge carries no padding; keep the ring inside. */ - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index ee2cc6ee16..295d3baead 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -39,7 +39,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 43664921b4..bd0bb877c3 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -34,7 +34,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 037803f7f2..2b02db4606 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -22,7 +22,7 @@ } :global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); } .card:hover { background: var(--plan-card-hover); } -.card:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } @@ -30,7 +30,7 @@ .cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } .reviewLink:hover { color: var(--dsw-alias-label-primary); } -.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } .message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 96286b3b16..c4380c9462 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -154,7 +154,7 @@ } .iconButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -267,7 +267,7 @@ } .cardOpen:focus-visible::after { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -373,7 +373,7 @@ } .deleteButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -397,7 +397,7 @@ } .partsFilter:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -1067,7 +1067,7 @@ label.registryOption { } .crumb:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/Checkbox.module.css b/packages/client/ui-primitives/src/Checkbox.module.css index a78e56c45a..c834dcc72d 100644 --- a/packages/client/ui-primitives/src/Checkbox.module.css +++ b/packages/client/ui-primitives/src/Checkbox.module.css @@ -18,7 +18,7 @@ } .checkbox input:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index d65fcc82d2..a05e3d6ebf 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -56,7 +56,7 @@ } .warning:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index c4164cd18f..7492157b09 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -27,7 +27,7 @@ } .copyable:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index fcba264b38..73ff979683 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -56,7 +56,7 @@ } .acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/Switch.module.css b/packages/client/ui-primitives/src/Switch.module.css index c038331a66..6d8d7f345a 100644 --- a/packages/client/ui-primitives/src/Switch.module.css +++ b/packages/client/ui-primitives/src/Switch.module.css @@ -31,7 +31,7 @@ } .switch:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 510120d2e1..8ed285e745 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -151,7 +151,7 @@ } .cardContent:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -239,7 +239,7 @@ } .groupToggle:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -297,7 +297,7 @@ } .switcher:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css index cf1aade88f..151719c5af 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css @@ -48,6 +48,6 @@ } .save:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index c0f617575d..432ffeb79e 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -58,7 +58,7 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 2px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-settings-plugins/src/client/fields.module.css index 4db120dc7c..c38eff8226 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-settings-plugins/src/client/fields.module.css @@ -60,7 +60,7 @@ } .helpButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c99c729fbb..e2b531df7f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -493,7 +493,7 @@ } .panelRow:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css index 90296e5d78..4f2c1b3348 100644 --- a/packages/client/ui-theme/src/styles/focus.css +++ b/packages/client/ui-theme/src/styles/focus.css @@ -1,3 +1,10 @@ +/* The standard ring geometry. A component inside a clipping container sets its + * own offset, and the dense tables and toolbars keep a thinner 1px ring, rather + * than bending this value — those exceptions are deliberate, not drift. */ +:root { + --dsw-focus-ring-width: 2px; +} + /* One keyboard-focus colour for every control. A control that declares its own * focus ring sets the colour explicitly, so this sheet reaches only the ones * that would otherwise paint the browser default — whose colour follows the diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index d5442160be..f5a26ef446 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -255,4 +255,4 @@ } .path:hover { text-decoration: underline; } -.path:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.path:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 594c0b3570..05fddcebb3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -75,7 +75,7 @@ } .historyLoadButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -1763,7 +1763,7 @@ } .attachmentDisclosure > summary:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index c7875b946c..483abb218d 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -17,7 +17,7 @@ } .runHeader:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -80,7 +80,7 @@ } .phaseHeader:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; border-radius: 4px; } @@ -210,7 +210,7 @@ } .memberButton:focus-visible .memberLabelWrap { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 7340b2d7b8..2e692a9fe4 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -35,7 +35,7 @@ .iconButton:focus-visible, .searchButton:focus-visible, .clearButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } From 31a83ebc4bb97217126430000d2ce8dd947d56f4 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 19:01:15 +0800 Subject: [PATCH 009/253] refactor(client): let chrome rows own the macOS window drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS window's draggable area was a hand-written band: a 52px strip, deepened to 76px by a marker the Conversation header had to keep in step with its own height. It drifted from the layout — blank runs inside a chrome row did not drag, and a control a few pixels away dragged the window instead. Chrome rows now mark their own element (`data-window-drag`) and the shell declares the surface once (`html[data-platform='darwin'] [data-window-drag]`), so a row's own box is its draggable geometry and no fixed band has to match any row's height. The frame band, its 52/76 figures, the marker, and the per-sheet drag rules are gone; interactive controls and overlays portal beside `#root` where one rule subtracts them. Recollection becomes one shell watcher (`window-drag/recall.ts`): Electron rebuilds the window's drag rects only when a computed app-region value changes (electron#32341), so the watcher measures every marked row each frame while the surface moves, sets `data-window-drag-recall` for the frames whose geometry changed, and clears it on the frame that finds the geometry settled. The right panel's hand-written pulse is deleted with it. Coverage is a contract with executable parts. `window-drag/regions.ts` publishes the composition rule and the interactive selector that both the sheet and its spec compare against; the ui-theme gate asserts the package tree declares drag in exactly two places and pairs every row's mark with its sheet, selector, and authored height; and the browser lane boots the shipped composition in Chromium with the darwin marker to assert per-row geometry, a content body under a marked head row, a covering overlay, and the watcher's pulse. The plugin manager's detail views mark the head row they share rather than the pane they render in, so their text and form labels stay content. Both real machines agree: the macOS matrix ran on a real `hiddenInset` window with HID-level pointer input and window-frame readback, and the Windows matrix on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, and the controls keep their clicks. Refs #4619 --- ...19-window-drag-coverage-contract.i18n.yaml | 6 + ...026-09-19-window-drag-coverage-contract.md | 63 +++ ...-09-19-window-drag-coverage-contract.zh.md | 63 +++ ...3-macos-hidden-titlebar-vibrancy.i18n.yaml | 4 +- ...26-09-13-macos-hidden-titlebar-vibrancy.md | 8 +- ...09-13-macos-hidden-titlebar-vibrancy.zh.md | 8 +- apps/web/tests/window-drag-coverage.e2e.ts | 492 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationHeader.tsx | 2 +- .../skeleton/ConversationRoot.module.css | 14 +- .../client/skeleton/ConversationSession.tsx | 6 +- .../tests/skeleton.client.spec.tsx | 2 +- .../ui-dockkit/src/components/TabPanel.tsx | 2 +- .../src/components/dockkit.module.css | 21 +- .../tests/app-region-styles.client.spec.ts | 50 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 40 +- .../client/ui-layout/src/client/AppFrame.tsx | 23 - .../ui-layout/tests/app-frame.client.spec.tsx | 8 +- .../src/client/PluginManagerPage.module.css | 19 +- .../src/client/PluginManagerPage.tsx | 6 +- .../client/ui-settings-general/package.json | 4 +- .../src/client/SettingsRoot.module.css | 9 +- .../src/client/SettingsRoot.tsx | 9 +- .../src/client/shell/SidebarRight.module.css | 28 +- .../src/client/shell/SidebarRight.tsx | 33 +- .../tests/region-nudge-styles.client.spec.ts | 46 -- .../tests/seat.client.spec.tsx | 41 -- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 25 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 4 +- .../sidebar-snapshot.client.spec.tsx.snap | 5 + .../tests/app-region-styles.client.spec.ts | 272 ++++++++-- .../client/ui-theme/tests/stylesheet-scan.ts | 21 +- packages/client/web/src/base.css | 44 +- packages/client/web/src/boot.ts | 9 + packages/client/web/src/window-drag/recall.ts | 171 ++++++ .../client/web/src/window-drag/regions.ts | 106 ++++ .../web/tests/base-styles.client.spec.ts | 36 +- .../tests/window-drag-recall.client.spec.ts | 375 +++++++++++++ .../tests/window-drag-regions.client.spec.ts | 69 +++ pnpm-lock.yaml | 170 +++--- tsconfig.host.json | 4 +- 50 files changed, 1931 insertions(+), 412 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md create mode 100644 apps/web/tests/window-drag-coverage.e2e.ts delete mode 100644 packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts create mode 100644 packages/client/web/src/window-drag/recall.ts create mode 100644 packages/client/web/src/window-drag/regions.ts create mode 100644 packages/client/web/tests/window-drag-recall.client.spec.ts create mode 100644 packages/client/web/tests/window-drag-regions.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml new file mode 100644 index 0000000000..1138c29772 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md +2026-09-19-window-drag-coverage-contract.md: 71d9b192de7b10e10462c3f9360b1e2f56644b77 +2026-09-19-window-drag-coverage-contract.zh.md: edbcc010e69e1b6b9f7e02761efaa8798d338801 diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md new file mode 100644 index 0000000000..71d9b192de --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md @@ -0,0 +1,63 @@ +# Agent Note: Window drag coverage as a contract + +Status: implemented + +English | [中文](2026-09-19-window-drag-coverage-contract.zh.md) + +## Problem + +The macOS desktop window's draggable area is authored twice. The layout decides where chrome rows are — their heights and what they contain — while hand-written app-region declarations decide what drags: one row declaration per chrome row (the sidebar's top strip and logo row, ui-dockkit's strip row, the Conversation header, the plugin manager's page heads) plus per-box subtractions spread across ui-web, ui-settings-general and ui-sidebar-right. The two descriptions are kept in step by hand, so they drift: a row whose box does not match the band leaves chrome outside the drag surface (the reported "the header drags, but some blank runs and controls do not") or puts content inside it (a press that misses a control by a few pixels drags the window, and a double click there runs the system's title-bar action). + +## Decision + +Coverage becomes a contract with four executed parts: + +1. ui-web's `window-drag/regions.ts` publishes the composition rule as an executable model — a point is draggable when the last collected app-region box containing it declares `drag`, the geometry-in-DOM-order composition the native window applies — together with `INTERACTIVE_SELECTOR`, the single source for the interactive subtraction ui-web base.css declares. The base-styles spec asserts the sheet declares exactly that list, so the two cannot drift. +2. The shell declares the darwin drag surface exactly once: `html[data-platform='darwin'] [data-window-drag]` in ui-web base.css. Every chrome row marks its own element in markup, so the row's box is the window's draggable geometry and no fixed band has to match any row's height. The ui-theme app-region gate holds a manifest that pairs each row's markup mark with its sheet, its class selector and its authored height, and refuses a mark on any element the manifest does not name — so a row that stops marking itself, an unmarked row added to the composition, and a mark on a content container all fail the same gate. +3. The browser lane gains `window-drag-coverage`, which boots the shipped composition in Chromium with `data-platform='darwin'` and asserts, through the model, that no interactive box lies inside the drag surface, plus per-row probes for the chrome rows and a page whose head row marks itself while its scrollable body stays content. +4. The shell owns the one recollection watcher — ui-web's `window-drag/recall.ts`, installed by the boot kernel. It measures every marked row once per frame while the surface can be moving, sets `data-window-drag-recall` on the body for each frame whose geometry changed, and clears it on the frame that finds the geometry settled. No chrome row carries a pulse of its own. + +The native half — whether a press really drags or reaches the page — stays a checklist, because no keyless CI lane reaches the window server: the state matrix below is run for any change to this layer. + +## Alternatives considered + +**Publishing measured rectangles from JavaScript as synthetic drag boxes.** Geometry would have to be re-derived at pointer cadence, and one stale frame is exactly the swallow class this work removes; CSS-derived regions cannot be stale by construction. + +**A global background drag (the `movableByWindowBackground` reading).** Dragging anywhere that is not a control would break text selection and blank-area clicks. + +**One drag declaration per row in each row's own sheet.** That was this work's first shape, with a manifest pinning each row's height and the band over it. Every sheet then restates the window's geometry, and a row whose box moves in layout alone still has to be re-declared in CSS; one shell rule over a markup mark keeps the declaration count at one, and the manifest pairs the marks with the geometry that the gate can still check. + +**Pinning current coverage as one golden snapshot.** That would freeze the two failure classes next to the correct behaviour; the manifest separates the invariant (no swallowed control) from the gaps (a budget that only shrinks). + +## Consequences + +- A new control needs no drag declaration: the interactive selector subtracts it. A new chrome row marks its element `data-window-drag` and adds a manifest entry pinning its height, so no band arithmetic and no other sheet changes. +- A row's own box is the whole draggable geometry, and a content container is never one. The plugin manager's detail views mark the head row they share, not the detail container they are rendered in: marking the container would drag the window over its text and form labels, and the lane samples the body below that head to keep it so. +- No coverage gap remains: every chrome row owns its run, and the gate asserts the whole package tree declares the drag surface in exactly two places — the shell's mark rule and the Windows caption row. The plugin manager's page head and its detail views' shared head row include the 48px clearance inside the row, so the entry-page gap closed with them. +- The settings overlay portals beside `#root` (like the Modal primitive) and dropped its per-sheet `no-drag` rule: a covering surface inside the root precedes the columns' chrome, so a drag row declared later would override its subtraction, and ui-web base.css's `body > :not(#root)` rule subtracts a portalled one wherever it overlaps a drag row. The browser lane asserts that placement with the overlay open. +- A row that slides across a hidden/visible edge cannot rely on the layout change alone: Electron recollects the window's drag rects only when a computed app-region value changes (electron#32341), and Blink skips hidden boxes when it collects them. The watcher closes that gap for every row at once — it re-arms on a DOM change that touches a marked row or on a marked row's box resizing, then pulses each frame in which a box moved — so the right panel carries no pulse of its own. + +## Testing + +- `packages/client/web/tests/window-drag-regions.client.spec.ts` — the composition rule, including order sensitivity and box edges. +- `packages/client/web/tests/base-styles.client.spec.ts` — base.css declares the model's interactive selector, the one darwin drag rule, and the recall mark's subtraction. +- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` — drag ownership, the row manifest, and each row's mark; a mark outside the manifest or a changed row height fails. +- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` — the strip row's own subtraction; its drag is the markup mark's, so the sheet declares none. +- `packages/client/web/tests/window-drag-recall.client.spec.ts` — the watcher through its two seams: what re-arms it, what it ignores, the settle loop, the shell defaults, and disposal. +- `apps/web/tests/window-drag-coverage.e2e.ts` — real Chromium: no swallowed control, per-row probes, a detail page whose head row drags while its body does not, and the recall pulse the shell fires while the right panel slides. +- Real-machine verification (2026-09-20, both platforms, HID-level pointer input plus window-frame readback): the macOS matrix above ran on a real `hiddenInset` window and the Windows cells ran on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, controls keep their clicks, and the right panel's strip row drags after its slide. + +## Manual state matrix + +Run on the packaged macOS app for any change to this layer. Each cell means both halves: drag the row's blank run (the window must move) and click the row's controls (they must act rather than drag). + +| State | Rows to walk | +| --- | --- | +| Sidebar expanded, Conversation selected, view tabs shown | sidebar top strip, sidebar logo row, conversation title row, conversation tab-strip row, every control in those rows | +| Sidebar expanded, Conversation selected, single view (no tabs) | same rows, plus the transcript's first rows below the header | +| Sidebar collapsed | Conversation header over the centre, `shell.leading` seat controls, reopen and New session | +| Right sidebar pushed | panel strip row and its controls, the pane body run below the strip, the pane divider's top run | +| Right sidebar fullscreen | panel strip row at the traffic lights, first pane's strip inset, its controls | +| Entry page (guide, plugin manager, settings) | page head with its 48px clearance, its first control row, and a detail page's head row over its body | +| Window fullscreen on/off | every row above with the traffic lights hidden, in both sidebar states | +| Double click in a chrome row's blank run | on Windows the caption row takes the system title-bar action (maximize/restore); on macOS the darwin rows are app-region areas rather than a title bar, so no system action is expected there — only the controls keep their own double click | diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md new file mode 100644 index 0000000000..edbcc010e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md @@ -0,0 +1,63 @@ +# Agent Note: 把窗口拖拽覆盖变成契约 + +Status: implemented + +[English](2026-09-19-window-drag-coverage-contract.md) | 中文 + +## Problem + +macOS 桌面窗口的可拖区域被写了两遍。布局决定 chrome 行在哪——它们多高、装了什么;手写的 app-region 声明决定什么能拖:每个 chrome 行一条行声明(侧栏顶条与 logo 行、ui-dockkit 的 strip 行、会话 header、插件管理的页头),加上散落在 ui-web、ui-settings-general、ui-sidebar-right 的逐盒减除。两份描述靠人肉保持一致,于是会漂:盒子与 band 不匹配的行,要么有 chrome 落在拖拽面之外(就是反馈里的"header 能拖,但有些空白与控制不能"),要么有内容落进拖拽面(点偏几个像素就变成拖窗口,双击那里还会触发系统标题栏行为)。 + +## Decision + +覆盖变成由四部分组成的、可执行的契约: + +1. ui-web 的 `window-drag/regions.ts` 把合成规则发布成可执行模型——当包含某点的最后一个 app-region 盒声明 `drag` 时该点可拖,这正是原生窗口施加的"按 DOM 顺序的几何合成"——并发布 `INTERACTIVE_SELECTOR`,作为 ui-web base.css 交互减除的唯一来源。base-styles spec 断言样式表声明的正是该列表,两者无法漂移。 +2. 外壳只声明一次 darwin 拖拽面:ui-web base.css 里的 `html[data-platform='darwin'] [data-window-drag]`。每个 chrome 行在 markup 里给自己的元素打标,于是该行自己的盒子就是窗口的可拖几何,不需要任何固定 band 去匹配行高。ui-theme 的 app-region 门禁持有一份清单,把每行的 markup 标记与其样式表、类选择器和写死的高度配成一条,并拒绝清单未点名的元素上的标记——行不再打标、新增未打标的行、把标记打在内容容器上,都会在同一条门禁上失败。 +3. 浏览器车道增加 `window-drag-coverage`:以 `data-platform='darwin'` 在 Chromium 里启动真实组合,用模型断言没有任何交互盒落在拖拽面内,并对各 chrome 行逐一探测,另加一个"页头行打标、其可滚动正文保持内容"的页面。 +4. 外壳持有唯一那个重收集 watcher——ui-web 的 `window-drag/recall.ts`,由引导内核安装。在表面可能移动期间,它每帧测量每个被打标的行,凡该帧几何发生变化就在 body 上打 `data-window-drag-recall`,并在第一次发现几何稳定的那一帧清掉它。任何 chrome 行都不再自带脉冲。 + +原生那一半——一次按下到底是拖窗口还是到达页面——仍是清单,因为没有无密钥 CI 车道能到达窗口服务器:对本层的任何改动都要走下面的状态矩阵。 + +## Alternatives considered + +**用 JavaScript 测量矩形并发布合成 drag 盒。** 几何得按指针节拍重新推导,而一帧陈旧正是这项工作要消除的吞点击类别;CSS 推导的区域在构造上不可能陈旧。 + +**全局背景拖拽(`movableByWindowBackground` 的读法)。** 只要不是控件就能拖,会破坏文本选择与空白处点击。 + +**每个行在自己的样式表里各声明一条 drag。** 这是本项工作最初的样子,并配一份清单钉住每行高度与覆盖它的 band。那样每个样式表都要复述窗口几何,而且只在布局里移动的盒子仍得在 CSS 里重新声明;改成"markup 打标 + 外壳一条规则"后声明只有一处,清单则继续把标记与门禁能查的几何配起来。 + +**把当前覆盖钉成一张金标准快照。** 那会把两类故障和正确行为一起冻住;清单把不变量(不得吞掉控件)与缺口(只许缩小的预算)分开。 + +## Consequences + +- 新控件不需要任何 drag 声明:交互选择器会减除它。新 chrome 行只需给元素打上 `data-window-drag` 并在清单里加一条钉住高度,无需 band 算术,也不牵动其它样式表。 +- 行自己的盒子就是完整的可拖几何,内容容器永远不是。插件管理的详情视图给它们共用的页头行打标,而不是给它所在的详情容器打标:标记容器会让窗口在它的正文与表单 label 上拖动;车道会采样该页头下方的正文来守住这一点。 +- 不再有任何缺口:每个 chrome 行自持其拖拽段,门禁断言整个包树只在两处声明拖拽面——外壳的标记规则与 Windows 标题栏行。插件管理页头与其详情视图共用的页头行都把 48px 避让量包含在行内,入口页那条缺口也随之关闭。 +- settings 浮层现在与 Modal primitive 一样 portal 到 `#root` 之外,并删掉了它自己那条 no-drag:挂在 root 内的覆盖层先于各列 chrome,后声明的 drag 行会盖掉它的减除;portal 之后由 ui-web base.css 的 `body > :not(#root)` 规则在它与 drag 行重叠处减除。车道在浮层打开时会断言这个归属。 +- 跨越隐藏/显示边界滑动的行不能只靠布局变化:只有当计算出的 app-region 值变化时,Electron 才会重新收集窗口的拖拽矩形(electron#32341),而 Blink 收集时会跳过隐藏的盒子。watcher 一次性把这个缺口对所有行补上——它在"触及被打标行的 DOM 变化"或"被打标行的盒子尺寸变化"时重新启动,随后在盒子仍在移动的每一帧发脉冲——因此右栏不再自带任何脉冲。 + +## Testing + +- `packages/client/web/tests/window-drag-regions.client.spec.ts` —— 合成规则,含顺序敏感与盒子边界。 +- `packages/client/web/tests/base-styles.client.spec.ts` —— base.css 声明的正是模型的交互选择器、唯一那条 darwin drag 规则,以及 recall 标记的减除。 +- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` —— 拖拽所有权、行清单与每行的标记;清单之外的标记或行高变化即失败。 +- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` —— strip 行自己的减除;它的 drag 属于 markup 标记,因此样式表一条都不声明。 +- `packages/client/web/tests/window-drag-recall.client.spec.ts` —— 通过两个 seam 驱动 watcher:什么会重新启动它、什么被它忽略、收敛循环、外壳默认值,以及销毁。 +- `apps/web/tests/window-drag-coverage.e2e.ts` —— 真 Chromium:无控件被吞、逐行探测、一个页头行可拖而正文不可拖的详情页,以及右栏滑动期间外壳发出的 recall 脉冲。 +- 真机验收(2026-09-20,双平台,HID 级指针输入 + 窗口矩形读数):上面的 macOS 矩阵在真实 `hiddenInset` 窗口上跑过,Windows 各格在 Windows 11 ARM64 虚拟机上跑过——chrome 行可拖、正文与覆盖浮层不可拖、控件保留点击、右栏 strip 行在滑入后可拖。 + +## Manual state matrix + +对本层的任何改动都要在打包 macOS 应用上跑。每个格子包含两半:拖该行的空白段(窗口必须移动),点该行的控件(必须生效而不是拖动)。 + +| 状态 | 要走的行 | +| --- | --- | +| 侧栏展开、选中会话、显示视图 tab | 侧栏顶条、侧栏 logo 行、会话标题行、会话 tab 条行,以及这些行里的每个控件 | +| 侧栏展开、选中会话、单视图(无 tab) | 同上,外加 header 之下的转录区首行 | +| 侧栏收起 | 覆盖中列的 会话 header、`shell.leading` 座的控件、重新打开与新会话 | +| 右栏 push | 面板 strip 行及其控件、strip 下方的 pane body 段、分栏分隔条的顶部一段 | +| 右栏 fullscreen | 红绿灯处的面板 strip 行、首格 strip 内边距、其控件 | +| 入口页(开始页、插件管理、设置) | 含 48px 避让量的页头、它的首个控件行,以及详情页页头行与其正文 | +| 窗口全屏开关 | 以上各行在红绿灯隐藏时、两种侧栏状态各一遍 | +| 在 chrome 行空白段双击 | Windows 上 caption 行走系统标题栏行为(最大化/还原);macOS 上 darwin 行只是 app-region 区域而非标题栏,因此那里没有系统动作——只有控件保留自己的双击 | diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index d15870537e..99ecd869cb 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md -2026-09-13-macos-hidden-titlebar-vibrancy.md: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a +2026-09-13-macos-hidden-titlebar-vibrancy.md: 005bf783bf7396446313b598b25255bff3a8ad7a +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 17b746962d5ffd02f23769f17c7b71170cb9689e diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index 807c79d06c..005bf783bf 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -18,11 +18,11 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o **Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`. -**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the frame's `.leadingBand` drag band covers the same 52px geometry, so the strip declares no app-region of its own and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md). +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the strip marks itself `data-window-drag`, so its own 52px box is the window's drag region (ui-web base.css declares the one darwin drag rule) and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md). **Window fullscreen marker.** macOS fullscreen hides the traffic lights, so layouts built around them must relax. The main process relays `isFullScreen()` over `dsh-desktop:window-fullscreen` on `enter-full-screen`, `leave-full-screen`, and each `did-finish-load`; the app preload's `syncWindowFullscreen()` mirrors it onto `html[data-fullscreen]`. Consumers: the AppFrame drops `--dsh-frame-leading-clearance` to 84px and moves the seat to `left: 12px`, the sidebar's `.topStrip` moves the toggle to the strip's left edge, and the right sidebar's fullscreen panel resets its first pane's dockkit strip inset to the ordinary 10px. -**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared only inside the two allowlisted stylesheets: ui-layout `AppFrame.module.css` (the darwin `.leadingBand`, a 52px full-width `pointer-events: none` strip rendered as the frame's first child, and the Windows caption `.frame::before`) and ui-sidebar `SidebarRoot.module.css` (`.logoRow`, which extends the band below 52px). Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. Because the drag bands mount before all content, any later no-drag box wins; content containers never declare drag, which would override earlier-mounted overlays. +**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so the shell declares `-webkit-app-region: drag` once, over the mark a chrome row puts on its own box, each row's box being its draggable geometry: the sidebar's top strip and logo row (ui-sidebar), the right sidebar's dockkit strip (ui-dockkit), the Conversation header (ui-conversation), and the plugin manager's page head and shared detail head (ui-plugin-manager). The frame declares none — the Windows caption row is that platform's own chrome — so no fixed band has to match any row's height. Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. The ordering rule those two rest on: a surface that covers the window and must stay clickable portals beside `#root` (the settings overlay does), which puts it after every drag row in document order; content containers never declare drag, which would override earlier-mounted overlays. The [coverage contract note](../architecture/2026-09-19-window-drag-coverage-contract.md) owns the manifest, the browser lane, and the manual state matrix. **Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth. @@ -43,9 +43,9 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o - The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets. - The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering. - The reopen controls occupy the frame's `shell.leading` seat, a public slot in the client catalog; the earlier `conversation.session.header.leading` seat is removed ([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md)). -- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only inside the two allowlisted stylesheets (ui-layout `AppFrame.module.css`: darwin `.leadingBand` and Windows caption `.frame::before`; ui-sidebar `SidebarRoot.module.css`: `.logoRow`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region stylesheet gate enforces the allowlist over every package stylesheet. +- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only in ui-web `base.css` (the one darwin rule over `data-window-drag`) and in ui-layout `AppFrame.module.css` (the Windows caption `.frame::before`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region gate pairs each chrome row's markup mark with its sheet, selector, and authored height, and refuses a mark anywhere else. - The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash. ## Testing -ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet. +ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region gate pins the one darwin drag rule and every chrome row's markup mark. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index a521aff762..17b746962d 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -18,11 +18,11 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig **原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。 -**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;框架的 `.leadingBand` 拖拽带覆盖同一 52px 几何,顶部条自身不声明 app-region,按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;顶部条自己打上 `data-window-drag`,于是它自身那 52px 盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。 **窗口全屏标记。** macOS 全屏隐藏红绿灯,围绕它们构建的布局必须放松。主进程在 `enter-full-screen`、`leave-full-screen` 与每次 `did-finish-load` 时经 `dsh-desktop:window-fullscreen` 转发 `isFullScreen()`;应用 preload 的 `syncWindowFullscreen()` 把它镜像到 `html[data-fullscreen]`。消费者:AppFrame 把 `--dsh-frame-leading-clearance` 降到 84px 并把座移到 `left: 12px`,侧边栏 `.topStrip` 把收起按钮移到条带左端,右侧边栏全屏面板把首格 pane 的 dockkit strip 内边距重置回常规 10px。 -**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两个允许清单样式表内声明:ui-layout `AppFrame.module.css`(darwin 的 `.leadingBand`——52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带——与 Windows 顶栏 `.frame::before`)和 ui-sidebar `SidebarRoot.module.css`(`.logoRow`,把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。 +**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此外壳只声明一次 `-webkit-app-region: drag`,作用于 chrome 行打在自身盒子上的标记,行自身的盒子就是它的可拖几何:侧栏顶条与 logo 行(ui-sidebar)、右侧边栏的 dockkit strip(ui-dockkit)、会话 header(ui-conversation)、插件管理的页头与共用的详情页头(ui-plugin-manager)。框架不再声明任何 drag——Windows 标题栏行是那个平台自己的 chrome——因此没有一条固定带子需要匹配某一行的高度。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。这两条规则依赖的顺序约定:覆盖窗口且必须可点的表面 portal 到 `#root` 之外(settings 浮层即如此),从而在文档顺序上晚于所有 drag 行;内容容器永不声明 drag——那会覆盖更早挂载的浮层。清单、浏览器车道与人工状态矩阵归 [覆盖契约 note](../architecture/2026-09-19-window-drag-coverage-contract.zh.md)。 **全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。 @@ -43,9 +43,9 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 - 重新打开控件占据框架的 `shell.leading` 座,是客户端 catalog 中的公开 slot;早先的 `conversation.session.header.leading` 座已移除([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md))。 -- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在两个允许清单样式表内(ui-layout `AppFrame.module.css`:darwin `.leadingBand` 与 Windows 顶栏 `.frame::before`;ui-sidebar `SidebarRoot.module.css`:`.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。 +- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在 ui-web `base.css`(作用于 `data-window-drag` 的唯一 darwin 规则)与 ui-layout `AppFrame.module.css`(Windows 顶栏 `.frame::before`)内;其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 门禁把每个 chrome 行的 markup 标记与它的样式表、选择器和写死高度配成一条,并拒绝别处的标记。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 门禁钉住唯一那条 darwin drag 规则与每个 chrome 行的 markup 标记。 diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts new file mode 100644 index 0000000000..80df040862 --- /dev/null +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -0,0 +1,492 @@ +// Browser coverage for the macOS window drag surface: which chrome rows drag, +// which pixels stay content, and that no control is swallowed by the band. +// +// The lane boots the real composition in Chromium and marks the document root +// darwin — the one platform where these rules exist (ui-web base.css and +// ui-layout AppFrame scope every app-region rule to it). CDP-injected clicks +// cannot decide this class: the swallow happens in Electron's native window, so +// the claims here are geometry claims decided by the shared composition model +// (ui-web window-drag/regions.ts). +// +// Probes are anchored to stable data hooks and derived from live rects, so a +// layout change moves them rather than invalidating them. The ui-theme app-region +// gate holds the other half of the claim: it pins each row's mark, its sheet, and +// its authored height, and refuses a `data-window-drag` mark anywhere else. A +// probe whose expectation is not yet met carries `gap` and fails loudly, so a +// recorded gap shrinks here first and the manifest follows. +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + INTERACTIVE_SELECTOR, RECALL_MARK, isDraggableAt, type RegionRect, +} from '@deepseek-ai/dsh-client-web/src/window-drag/regions.ts' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +/** One collected box: its app-region value, whether it is interactive, and where it is. */ +interface CollectedRegion extends RegionRect { + /** Whether the element itself matches the interactive selector. */ + readonly interactive: boolean + /** Sample points at which the browser's hit test reaches this element or its content. */ + readonly hits: readonly (readonly [number, number])[] +} + +/** A point the coverage table claims something about. */ +interface CoverageProbe { + /** Row the probe speaks for, for the failure message. */ + readonly row: string + /** What the point is, for the failure message. */ + readonly what: string + /** Resolves the point from the live rects captured for the state under test. */ + readonly at: () => readonly [number, number] + /** Whether the window would drag there. */ + readonly drag: boolean + /** Set when the expectation is a recorded gap the refactor must close. */ + readonly gap?: string +} + +/** A viewport rect. */ +interface Rect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 } + +/** A browser-half bundle whose row registers configuration into the plugin page. */ +const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) + +/** Read one hook's viewport rect, or a zero rect while it is not rendered. */ +async function rectOf(page: Page, selector: string): Promise { + const locator = page.locator(selector).first() + if (await locator.count() === 0) return EMPTY + return locator.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) +} + +/** + * Collect every visible app-region box in DOM order. + * @param page - the page under test. + * @returns the boxes, in document order, with the interactive flag of each element. + */ +async function collectedRegions(page: Page, selector: string): Promise { + return page.evaluate((interactiveSelector) => { + const collected: { + x: number + y: number + width: number + height: number + draggable: boolean + interactive: boolean + hits: [number, number][] + }[] = [] + for (const element of Array.from(document.querySelectorAll('*'))) { + const style = getComputedStyle(element) + const region = style.getPropertyValue('-webkit-app-region') + if (region !== 'drag' && region !== 'no-drag') continue + if (style.visibility === 'hidden' || style.display === 'none') continue + const rect = element.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) continue + const interactive = element.matches(interactiveSelector) + const insetX = Math.min(2, rect.width / 4) + const insetY = Math.min(2, rect.height / 4) + const samples: [number, number][] = [ + [rect.x + rect.width / 2, rect.y + rect.height / 2], + [rect.x + insetX, rect.y + insetY], + [rect.x + rect.width - insetX, rect.y + insetY], + [rect.x + insetX, rect.y + rect.height - insetY], + [rect.x + rect.width - insetX, rect.y + rect.height - insetY], + ] + const hits = interactive + ? samples.filter(([x, y]) => { + const top = document.elementFromPoint(x, y) + return top !== null && (top === element || element.contains(top)) + }) + : [] + collected.push({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + draggable: region === 'drag', + interactive, + hits, + }) + } + return collected + }, selector) +} + +/** + * Interactive boxes whose press cannot reach the page, as printable descriptions. + * A sample counts only where the browser's own hit test reaches the control: a + * box clipped away by its column (the collapsed sidebar's hidden controls sit at + * negative x) is not hittable anywhere it overlaps a drag row, and a press there + * belongs to whatever is painted. + * @param regions - collected boxes in DOM order. + * @returns one description per interactive box with a hittable sample inside the drag surface. + */ +function swallowedBoxes(regions: readonly CollectedRegion[]): string[] { + return regions + .filter(region => region.interactive) + .filter(region => region.hits.some(([x, y]) => isDraggableAt(regions, x, y))) + .map(region => `${Math.round(region.x)},${Math.round(region.y)} ${Math.round(region.width)}x${Math.round(region.height)}`) +} + +/** + * Settle when the shell first pulses the drag recall mark, or false when 15s pass + * without one. Start it before the gesture under test and await it after. + * @param page - the page under test. + * @returns the pending observation of the first pulse. + */ +function firstRecallPulse(page: Page): Promise { + return page.evaluate((mark: string) => new Promise((resolve) => { + const observer = new MutationObserver((records) => { + const pulsed = records.some(record => record.attributeName === mark + && (record.target as Element).hasAttribute(mark)) + if (!pulsed) return + observer.disconnect() + resolve(true) + }) + observer.observe(document.body, { attributes: true, attributeFilter: [mark] }) + setTimeout(() => { + observer.disconnect() + resolve(false) + }, 15_000) + }), RECALL_MARK) +} + +/** + * Wait until nothing under the page is animating, so a probe measures the settled + * layout instead of a frame of a column slide. + * @param page - the page under test. + */ +async function settled(page: Page): Promise { + await expect.poll(() => page.evaluate(() => document.getAnimations() + .every(animation => animation.playState === 'finished' || animation.playState === 'idle')), + { timeout: 20_000 }).toBe(true) +} + +describe('web e2e: macOS window drag coverage', () => { + let scaffold: WebScaffold + let browser: Browser + + beforeAll(async () => { + // A manageable profile: the plugin page draws its card list — and so its + // detail views — only when the deployment runs a profile it can manage. + scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, + }) + browser = await chromium.launch() + }, 180_000) + + afterAll(async () => { + try { await browser?.close() } + finally { await scaffold?.close() } + }) + + /** + * Open a darwin-marked page in the shipped composition. + * @returns the page and its console tripwire. + */ + async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType }> { + const page = await newEnglishPage(browser) + await page.addInitScript((value: string) => { + const mark = (): void => { document.documentElement.setAttribute('data-platform', value) } + if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) + else mark() + }, 'darwin') + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + return { page, tripwire } + } + + it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => { + const { page, tripwire } = await darwinPage() + try { + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected workspace did not create an Agent') + agent.session.append('turn/start', { turn: 1 }) + const pulsing = firstRecallPulse(page) + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-panel][data-sidebar-right-open]').waitFor({ timeout: 15_000 }) + // The panel slides in from the frame's right edge; probing before the slide + // settles measures the hidden position, where the band does not reach. + await expect.poll(async () => page.locator('[data-sidebar-right-panel]').evaluate((element) => { + const rect = element.getBoundingClientRect() + return Math.round(innerWidth - rect.right) === 0 + && element.getAnimations({ subtree: true }) + .every(animation => animation.playState === 'finished' || animation.playState === 'idle') + }), { timeout: 15_000 }).toBe(true) + + // The shell's watcher, not the panel, is what keeps the native drag rects in + // step with the slide: it must have pulsed while the panel moved and must be + // settled now — a mark left set would subtract the whole surface. + expect(await pulsing, 'the shell pulses the drag recall mark while the panel slides').toBe(true) + // The watcher clears the mark on the frame after the geometry settles. + await expect.poll( + () => page.evaluate((mark: string) => document.body.hasAttribute(mark), RECALL_MARK), + { timeout: 5_000 }, + ).toBe(false) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const strip = await rectOf(page, '[data-dockkit-strip]') + const panel = await rectOf(page, '[data-sidebar-right-panel]') + const tabs = await rectOf(page, '[data-conversation-tabs]') + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + const seat = await rectOf(page, '[data-shell-leading]') + // The sidebar column drags through its own rows (ui-sidebar), so its + // coverage is the rows' geometry rather than a frame band's height. + const toggle = await rectOf(page, 'button[aria-label="Collapse sidebar"]') + + const probes: CoverageProbe[] = [ + { + row: 'right sidebar panel strip', + what: 'the strip row top inset, above the controls', + at: () => [strip.x + strip.width / 2, strip.y + 4], + drag: true, + }, + { + row: 'right sidebar panel strip', + what: 'the run below the strip row, over the pane body', + at: () => [panel.x + panel.width / 2, strip.y + strip.height + 4], + drag: false, + }, + { + row: 'conversation header', + what: 'the blank run right of the view tabs, on the tab strip row', + at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2], + drag: true, + }, + { + row: 'conversation header', + what: 'the title row blank run', + at: () => [header.x + header.width / 2, header.y + 4], + drag: true, + }, + { + row: 'conversation header', + what: 'the transcript run below the header block', + at: () => [header.x + header.width / 2, header.y + header.height + 4], + drag: false, + }, + ] + if (toggle.width > 0) { + probes.push( + { + row: 'sidebar top strip', + what: 'the blank run left of the collapse toggle', + at: () => [toggle.x - 40, toggle.y + toggle.height / 2], + drag: true, + }, + { + row: 'sidebar column', + what: 'the content run below the chrome rows', + at: () => [toggle.x - 40, toggle.y + toggle.height + 70], + drag: false, + }, + ) + } + if (seat.width > 0) { + probes.push({ + row: 'shell.leading seat', + what: 'the row of window-chrome controls', + at: () => [seat.x + 4, seat.y + seat.height / 2], + drag: false, + }) + } + + for (const probe of probes) { + const [x, y] = probe.at() + const expected = `${probe.row}: ${probe.what} at ${Math.round(x)},${Math.round(y)}` + + (probe.gap === undefined ? '' : ` (recorded gap — ${probe.gap})`) + expect(isDraggableAt(regions, x, y), expected).toBe(probe.drag) + } + + // The invariant that has no gap budget: a press inside any control box must + // reach the page instead of dragging the window. + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage') + throw error + } finally { + await page.close() + } + }, 180_000) + + it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 }) + await settled(page) + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + const seat = await rectOf(page, '[data-shell-leading]') + + // With the column hidden the centre starts at the frame's left edge, where + // the Conversation header owns the run. + expect( + isDraggableAt(regions, header.x + 40, header.y + 4), + 'the Conversation header over the frame’s left edge', + ).toBe(true) + expect( + isDraggableAt(regions, seat.x + seat.width / 2, seat.y + seat.height / 2), + 'the seat holding the reopen controls', + ).toBe(false) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-collapsed') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps an entry page’s chrome row draggable', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const head = page.locator('[class*="pageHead"]').first() + await head.waitFor({ timeout: 15_000 }) + await settled(page) + const rect = await head.evaluate((element) => { + const box = element.getBoundingClientRect() + return { x: box.x, y: box.y, width: box.width, height: box.height } + }) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + // The page head owns its run, clearance included: that is the entry page's + // window strip, with no frame band behind it. + expect( + isDraggableAt(regions, rect.x + 40, rect.y + 8), + 'the plugin manager page head', + ).toBe(true) + expect( + isDraggableAt(regions, rect.x + 40, rect.y + rect.height + 40), + 'the page body below the head', + ).toBe(false) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-entry-page') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const cards = page.locator('[data-plugin-package], [data-plugin-item]') + await cards.first().waitFor({ timeout: 30_000 }) + await settled(page) + // The card's head carries the one control that opens its page. + await cards.first().locator('button').first().click() + // The mark sits on the head row the detail views share, not on the detail + // container: marking the container would drag the window over its text and + // form labels wherever no control covers them. + const detail = page.locator('[data-plugin-item-detail], [data-plugin-detail], [data-plugin-row-detail]').first() + await detail.waitFor({ timeout: 15_000 }) + const geometry = await detail.evaluate((element) => { + const detailBox = element.getBoundingClientRect() + const headBox = element.firstElementChild?.getBoundingClientRect() + return { + detail: { x: detailBox.x, y: detailBox.y, width: detailBox.width, height: detailBox.height }, + head: headBox === undefined + ? null + : { x: headBox.x, y: headBox.y, width: headBox.width, height: headBox.height }, + viewport: { width: innerWidth, height: innerHeight }, + } + }) + if (geometry.head === null) throw new Error('the detail view rendered no head row') + const { detail: detailRect, head: headRect, viewport } = geometry + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + + // The head's top inset is the window clearance above the crumb: blank, so + // it drags like the entry page's head. + expect( + isDraggableAt(regions, headRect.x + headRect.width / 2, headRect.y + 4), + 'the detail head row’s top inset', + ).toBe(true) + + // Below the head, a point that no control covers is content: pressing the + // description, a label, or a gap in the form must reach the page. + const interactive = regions.filter(region => region.interactive) + const bodyTop = headRect.y + headRect.height + const bodyBottom = Math.min(detailRect.y + detailRect.height, viewport.height - 4) + const dragging: string[] = [] + for (let row = 0; row < 8; row += 1) { + for (let column = 0; column < 5; column += 1) { + const x = detailRect.x + (detailRect.width * (column + 0.5)) / 5 + const y = bodyTop + ((bodyBottom - bodyTop) * (row + 0.5)) / 8 + if (y <= bodyTop || y > viewport.height - 4 || x > viewport.width - 4) continue + if (interactive.some(region => x >= region.x && x < region.x + region.width + && y >= region.y && y < region.y + region.height)) continue + if (isDraggableAt(regions, x, y)) dragging.push(`${Math.round(x)},${Math.round(y)}`) + } + } + expect(dragging, 'content points inside the detail body that drag the window').toEqual([]) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-detail-page') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps a covering overlay out of the drag surface', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.locator('button[aria-haspopup="dialog"]').first().click() + const dialog = page.locator('[role="dialog"]').first() + await dialog.waitFor({ timeout: 15_000 }) + await settled(page) + const layer = await dialog.evaluate((element) => { + const overlay = element.parentElement + const rect = overlay?.getBoundingClientRect() + return { + parentIsBody: overlay?.parentElement === document.body, + coversWindow: rect !== undefined && rect.top <= 0 && rect.bottom >= innerHeight, + } + }) + // A covering surface inside #root precedes the columns' chrome, so a drag + // row declared later would override its subtraction; beside the root, + // ui-web base.css's body rule subtracts it instead. + expect(layer.parentIsBody, 'the covering overlay portals beside #root').toBe(true) + expect(layer.coversWindow).toBe(true) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + expect( + isDraggableAt(regions, header.x + header.width / 2, 8), + 'the window top behind an open covering overlay', + ).toBe(false) + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-overlay') + throw error + } finally { + await page.close() + } + }, 120_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4508e03def..67463cc363 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -143,6 +143,7 @@ "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", + "tests/window-drag-coverage.e2e.ts", "tests/complex-history.perf.ts", "tests/pwsh-terminal.e2e.ts", "tests/workflow-run.e2e.ts" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b871029e8d..55087f1601 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd -README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe +README.md: 2ab123cc67854bbac9631f74517633dcb625384d +README.zh.md: c852cdbad545aee75497bde79a3a6f50c4fc168e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 16e821ed4c..2ab123cc67 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -52,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. -A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. +A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7316b00c69..c852cdbad5 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 -blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 +blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx index a8f2e6b122..b3b07c2b6b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx @@ -15,7 +15,7 @@ export function ConversationHeader({ sessionId, useSession, useConversation, ren const blank = session === undefined || conversation === undefined || (session.blank && conversationPhase(session, conversation) === 'blank') return ( -
+
{renderSlot('conversation.header.leading', {})}
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 25530eef99..ed85896510 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -47,13 +47,13 @@ min-height: 0; } -/* macOS hiddenInset titlebar: window dragging comes from ui-layout's - .leadingBand (the frame's first child), never from this header. Electron - composes app-regions in document order ignoring stacking, so a drag rule - here would keep eating clicks through overlays mounted earlier in the DOM - (the settings modal lives in the sidebar column). The title-row clusters - overlap the band and subtract their whole boxes; individual controls are - covered by the base.css interactive-element rule. */ +/* macOS hiddenInset titlebar: the header block owns the window drag over its + own box — title row plus the view tab strip, so the draggable geometry is the + header's geometry and no frame band has to match its height. The header marks + itself `data-window-drag` (ConversationHeader); below it the title-row clusters + subtract their whole boxes, and individual controls are covered by the base.css + interactive-element rule. A covering overlay lives beside #root + (ui-settings-general), which puts it after this row in document order. */ :global([data-platform='darwin']) .headerLeading, :global([data-platform='darwin']) .headerActions, :global([data-platform='darwin']) .headerUtilities, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index d17f52e3f8..f1179cf32b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -75,7 +75,7 @@ export function ConversationSessionHeader({ const last = index === ancestry.length - 1 // The current crumb has no navigation, so it is plain text // rather than a disabled button: on darwin desktop a button - // would subtract itself from the window drag band (ui-web + // would subtract itself from the header's drag row (ui-web // base.css) and leave the title inert for dragging too. const title = last ? ( @@ -138,8 +138,8 @@ export function ConversationSessionHeader({ {showTabs && ( - // data-conversation-tabs: ui-layout's window drag band matches this - // marker (:has) to deepen only while the tab strip adds header height. + // data-conversation-tabs: marks the tab strip, which the window-chrome + // geometry and the browser coverage lane anchor on.
{tabs.map(viewTab => (
- + ) } @@ -1140,7 +1140,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsCards ? ( -
+

{t('title')}

{t('intro')}

diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index b0d0e950b6..b5eaf35c06 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -64,8 +64,10 @@ "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 8e7569b3ca..333f5fdafe 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -239,8 +239,7 @@ white-space: nowrap; } -/* macOS: overlays covering the window drag bands must subtract themselves - (Electron app-regions are geometry, not stacking). */ -:global([data-platform='darwin']) .overlay { - -webkit-app-region: no-drag; -} +/* No app-region rule here: the overlay portals beside #root (SettingsRoot), so + ui-web base.css's `body > :not(#root)` subtraction covers it wherever it + overlaps a chrome row's drag — including above every column, which an in-root + overlay could not promise once a chrome row declares drag itself. */ diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 6e82913513..90a1eceb66 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -11,6 +11,7 @@ * to the step, so a mounted-but-deciding step paints nothing here. */ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { ConnectionIndicator, @@ -67,7 +68,11 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP const closeButton = useRef(null) useEffect(() => { closeButton.current?.focus() }, []) - return ( + // Portalled beside #root like the Modal primitive: a covering surface mounted + // inside the root would precede the columns' chrome in document order, so a + // chrome row that declares window drag after it would override its subtraction. + // Beside the root, base.css's `body > :not(#root)` rule subtracts it instead. + return createPortal((
- ) + ), document.body) } /** diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 890b897cc8..eaee49200a 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -46,19 +46,6 @@ transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* One-frame app-region pulse at each open/close edge (RightbarSeat): changing - this computed value is what makes Electron recollect the window's drag - rects, which the slide's transform/visibility flips alone never dirty - (electron#32341). The panel's steady state sets no app-region in either - presentation, so this write is a real change at both edges — including a - hidden panel opening straight into fullscreen (a narrow window's first - open), whose rects were skipped while hidden. While set, the whole panel is - out of the drag band for that frame; the removal frame recollects the - steady-state geometry below. */ -:global(html[data-platform='darwin']) .panel[data-sidebar-right-region-nudge] { - -webkit-app-region: no-drag; -} - .panel[data-sidebar-right-panel='fullscreen'] { position: fixed; inset: 0; @@ -66,16 +53,11 @@ border: none; } -/* No whole-box subtraction here: Electron drag regions are window geometry, - not stacking, so the frame's leading band does keep acting through this - fixed overlay — but the kit already subtracts every box that must stay - usable (tabs, strip-end chrome, pane bodies, dividers; ui-dockkit), and - ui-web base.css subtracts interactive elements and portalled overlays. - What remains of the band under the strips' blank runs is the window drag - affordance, the same native-tab-bar reading as the push presentation. A - panel-wide no-drag would trade it away and, worse, make no-drag the - steady state, turning the open/close pulse above into a computed-value - no-op exactly when a hidden panel opens straight into fullscreen. */ +/* No whole-box subtraction here: the strip rows inside mark themselves + `data-window-drag`, and ui-web base.css subtracts interactive elements and + portalled overlays, so a strip's blank runs stay draggable through this + fixed overlay while its tabs and strip-end chrome keep their clicks. A + panel-wide no-drag would trade that drag affordance away. */ /* Windows keeps caption controls above fullscreen file and terminal panels. */ :global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] { diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 6b388b219f..b4853ca237 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -375,36 +375,9 @@ export function RightbarSeat({ if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) }, [actions, sessionId, shown, fullscreen, canShow]) - // Electron rebuilds the window's -webkit-app-region rects only when a style - // or layout pass dirties an app-region value (electron#32341), and Blink - // skips hidden boxes when it collects them. The open/close slide flips only - // transform and visibility — neither dirties the rects — so the strip's - // no-drag boxes (ui-dockkit) are missing right after the first open, leaving - // the tabs under the window drag band (ui-layout .leadingBand) swallowed by - // it, and they would linger after a close. Pulsing an app-region rule - // (SidebarRight.module.css) on the panel forces a recollection at both edges - // of the gesture: once at the flip, once after the slide settles and the - // stylesheet's delayed visibility flip (0.3s) has landed. - useEffect(() => { - if (document.documentElement.dataset.platform !== 'darwin') return - const panel = panelRef.current - if (panel === null) return - let raf: number | null = null - const nudge = (): void => { - panel.setAttribute('data-sidebar-right-region-nudge', '') - raf = requestAnimationFrame(() => { - raf = null - panel.removeAttribute('data-sidebar-right-region-nudge') - }) - } - nudge() - const settle = setTimeout(nudge, 400) - return () => { - clearTimeout(settle) - if (raf !== null) cancelAnimationFrame(raf) - panel.removeAttribute('data-sidebar-right-region-nudge') - } - }, [shown]) + // The open/close slide needs no pulse of its own: the shell's window drag + // watcher (ui-web) measures the marked rows every frame the surface moves and + // sets the recall mark itself (electron#32341). // Fullscreen leaves the previous column report in force until its own slide // completes. Normal presentation and zero-duration transitions report before paint. diff --git a/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts b/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts deleted file mode 100644 index e095849e1d..0000000000 --- a/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * App-region nudge pulse, asserted against SidebarRight.module.css on disk: - * Electron recollects the window's drag rects only when a style pass changes - * a computed app-region value (electron#32341), so the panel's steady state - * must declare NO app-region in either presentation — the open/close no-drag - * pulse is then a real computed-value change at both edges, including a - * hidden panel opening straight into fullscreen (a narrow window's first - * open, whose rects were skipped while hidden). A steady-state value on the - * panel (the old fullscreen whole-box no-drag) makes the pulse a silent - * no-op and leaves the frame's drag band swallowing the strip's top-right - * controls; fine-grained subtraction belongs to ui-dockkit (tabs, strip-end - * chrome, pane bodies, dividers), leaving the strips' blank runs draggable. - */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/client/shell/SidebarRight.module.css', import.meta.url), 'utf8') - -/** - * Every ` -> value` pair of rules declaring `-webkit-app-region`. - * @returns one entry per declaring rule, in source order. - */ -function appRegionRules(): [selector: string, value: string][] { - const rules: [string, string][] = [] - for (const match of css.matchAll(/(?[^{}]+)\{(?[^{}]*)\}/g)) { - const value = /-webkit-app-region:\s*([^;]+);/.exec(match.groups!['body']!)?.[1] - if (value !== undefined) rules.push([match.groups!['sel']!.trim(), value.trim()]) - } - return rules -} - -describe('app-region nudge pulse styles', () => { - it('pulses the panel to no-drag', () => { - const pulses = appRegionRules() - .filter(([selector]) => selector.includes('[data-sidebar-right-region-nudge]')) - expect(pulses).toHaveLength(1) - expect(pulses[0]![0]).toMatch(/\.panel\[data-sidebar-right-region-nudge\]\s*$/) - expect(pulses[0]![1]).toBe('no-drag') - }) - - it('declares no steady-state app-region, so the pulse is a real change', () => { - const steady = appRegionRules() - .filter(([selector]) => !selector.includes('[data-sidebar-right-region-nudge]')) - expect(steady).toEqual([]) - }) -}) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 417e3742ca..b1b8866bf9 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -268,47 +268,6 @@ describe('RightbarSeat presentation', () => { expect(h.frame.closeRightbar).toHaveBeenCalled() }) - it('skips the nudge while the darwin seat has no surface to render', async () => { - // The seat's first render returns null (the open effect has not created - // the surface yet), so the nudge effect fires with an unattached panel ref. - document.documentElement.dataset.platform = 'darwin' - try { - const h = await mountSeat() - const panel = element(h.view.container, '[data-sidebar-right-panel]') - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false) - } finally { - delete document.documentElement.dataset.platform - } - }) - - it('pulses the app-region nudge at each open and close edge on macOS only', async () => { - const h = await mountSeat() - const panel = element(h.view.container, '[data-sidebar-right-panel]') - const marks = vi.spyOn(panel, 'setAttribute') - const nudges = () => marks.mock.calls.filter(([name]) => name === 'data-sidebar-right-region-nudge').length - // Web and Windows compose no app-regions: the slide needs no nudge. - act(() => { h.controller.toggleExpanded() }) - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(0) - document.documentElement.dataset.platform = 'darwin' - try { - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(1) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true) - // A frame later the mark lifts (the pulse itself is the recollection - // trigger), and the settle pulse re-marks after the 0.3s slide. - await act(async () => { await new Promise(resolve => setTimeout(resolve, 450)) }) - expect(nudges()).toBe(2) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false) - // The close edge pulses again so the hidden panel's stale rects drop. - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(3) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true) - } finally { - delete document.documentElement.dataset.platform - } - }) - it('fills the viewport without replacing the content tree or releasing the wide track', async () => { const h = await mountSeat() const tab = h.open() diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 72b5b20666..7d64e76cb3 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e -README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5 +README.md: 9ee8d9eb95d0991f1cdfcee3b5beee4d249d3f50 +README.zh.md: c858362b4b8a8c5e41b337253454d41001f926fa diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 94bb3a70ca..9ee8d9eb95 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -45,7 +45,7 @@ On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in t ### macOS desktop -Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights and carries the collapse toggle; the frame's own drag band (ui-layout `.leadingBand`) covers that strip, and the logo row below extends the drag surface, so the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). +Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights and carries the collapse toggle; the strip and the logo row below each mark themselves `data-window-drag`, so each row's own box is the window's drag region (ui-web base.css declares the one darwin drag rule), and the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). ### Scrollbars diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 86f54882ba..c858362b4b 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -45,7 +45,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### macOS 桌面 -在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;框架自身的拖拽带(ui-layout `.leadingBand`)覆盖这条顶部条,其下的 logo 行延伸这块拖拽面,因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 +在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;顶部条与其下的 logo 行各自打上 `data-window-drag`,于是每行自己的盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 ### 滚动条 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 144070ba3f..972be46d74 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -176,8 +176,9 @@ /* macOS hiddenInset titlebar: the strip spans the column's top edge (negative margins cancel the root padding) so it clears the traffic lights at - (16, 18). Window dragging over this run comes from ui-layout's .leadingBand - (same 52px geometry); the toggle subtracts via the base.css rule. */ + (16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so + its own run is the column's draggable geometry and no frame band has to match + the strip's height; the toggle inside subtracts via the base.css rule. */ .topStrip { flex: none; display: flex; @@ -207,7 +208,15 @@ /* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the right edge; the rail gives every control the same base left anchor before - the shared entry translation is applied. */ + the shared entry translation is applied. macOS hiddenInset titlebar: the row + is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's + single darwin rule carries the drag over its own box — below the top strip + (40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides + that box (SidebarRoot renders the wordmark as a plain span there, so the + global interactive no-drag rule never subtracts it); the dedicated New + Session button below keeps the pointer and keyboard paths. Safe as a drag + source: it precedes the sidebar's overlay slots in document order, so their + no-drag still wins. */ .logoRow { flex: none; display: flex; @@ -228,16 +237,6 @@ justify-content: flex-start; } -/* macOS hiddenInset titlebar: the logo row extends the frame band's drag - below 52px, and the brand rides it — SidebarRoot renders the wordmark as a - plain span there, so the global interactive no-drag rule never subtracts - it; the dedicated New Session button below keeps the pointer and keyboard - paths. Safe as a drag source: it precedes the sidebar's overlay slots in - document order, so their no-drag still wins. */ -:global([data-platform='darwin']) .logoRow { - -webkit-app-region: drag; -} - /* Brand group (figma I133:7632): the full wordmark rides the text ink (figma flows: the main-screen instance is black; blue is brand emphasis only). A button only in behavior (New Session shortcut): the diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 12bcbe8ca8..90a978f03b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -208,8 +208,8 @@ export function SidebarRoot({ > {/* macOS hiddenInset titlebar: the strip shares the row with the traffic lights and keeps the toggle at the sidebar's top-right. */} - {darwinDesktop &&
{toggle}
} -
+ {darwinDesktop &&
{toggle}
} +
{/* Expanded, the brand doubles as a New Session shortcut — except on macOS, where it stays part of the logo row's window-drag surface (a button would subtract itself through the global no-drag rule); diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index d3f6bbfc56..a467c3dbba 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -11,6 +11,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded >
+
+

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+ {failed &&

{t('failed')}

} +
+
+ + +
+ +} diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index a6a8b79272..a5b1c715ab 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -120,6 +120,11 @@ export function apply(ctx: Context): void { } }, async cancel(id) { const result = await ctx.remote.account.cancelSignIn(id); if (!result.ok) throw new Error('account cancel failed') }, + async hasRunningAccountTasks() { + const result = await ctx.remote.account.hasRunningAccountTasks() + if (!result.ok) throw new Error('account task query failed') + return result.value + }, async signOut() { const result = await ctx.remote.account.signOut(); if (!result.ok) throw new Error('account sign-out failed') }, } ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index e332296af7..b93c8e61c0 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -9,7 +9,9 @@ export const en = { loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness', settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', - signIn: 'Sign in', signOut: 'Sign out', cancel: 'Cancel', open: 'Open browser', + signIn: 'Sign in', signOut: 'Sign out', + signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', + signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', completing: 'Completing sign in…', expired: 'Sign in expired. Try again.', failed: 'Could not complete the operation. Try again.', @@ -33,7 +35,9 @@ export const zh: Record = { loading: '加载中…', backToHarness: '返回 DeepSeek Harness', settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', - signIn: '登录', signOut: '退出登录', cancel: '取消', open: '打开浏览器', + signIn: '登录', signOut: '退出登录', + signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', + signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', completing: '正在完成登录…', expired: '登录已过期,请重试。', failed: '操作未完成,请重试。', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 9dfa3dd269..3757579a79 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -17,6 +17,7 @@ function mount(state: Omit, copy: typeof en | typeof zh = getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }), subscribe: () => () => {}, } }, + hasRunningAccountTasks: vi.fn(async () => false), contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(), refresh: vi.fn(() => Promise.resolve()), start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()), @@ -50,13 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh])('opens settings and signs out from the sidebar account menu', async (copy) => { +it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -70,6 +72,9 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu', expect(screen.queryByRole('menu')).toBeNull() fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) + expect(signOut).not.toHaveBeenCalled() + expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt new file mode 100644 index 0000000000..9e7ca254e0 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt @@ -0,0 +1 @@ +Sign outSigning out will not delete any data. You can sign in to this account again.CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt new file mode 100644 index 0000000000..9ad80dfc63 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt @@ -0,0 +1 @@ +退出登录退出登录不会丢失任何数据,你仍可以登录此账号。取消退出登录 diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt new file mode 100644 index 0000000000..005841b323 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt @@ -0,0 +1 @@ +Sign outTasks are currently running. Signing out will interrupt them. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt new file mode 100644 index 0000000000..b3effa3b49 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt @@ -0,0 +1 @@ +退出登录当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx new file mode 100644 index 0000000000..9ee95e897f --- /dev/null +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +/** Sign-out impact copy and explicit confirmation. */ +import { cleanup, fireEvent, render, screen, act } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { SignOutDialog } from '../src/client/SignOutDialog.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { + const close = vi.fn() + render( copy[key]} />) + return { signOut, close } +} +it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( + 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { + const props = mount(running, copy) + await expect(`${screen.getByRole('dialog').textContent}\n`) + .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + expect(props.signOut).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) + expect(props.signOut).toHaveBeenCalledOnce() + expect(props.close).toHaveBeenCalledOnce() + }, +) +it.each(['cancel', 'close', 'escape'] as const)('dismisses with %s without signing out', (action) => { + const props = mount(true) + if (action === 'escape') fireEvent.keyDown(document, { key: 'Escape' }) + else fireEvent.click(screen.getByRole('button', { name: en[action] })) + expect(props.close).toHaveBeenCalledOnce() + expect(props.signOut).not.toHaveBeenCalled() +}) +it('keeps failures open for retry and blocks dismissal during sign-out', async () => { + const pending = Promise.withResolvers() + const signOut = vi.fn(() => pending.promise).mockRejectedValueOnce(new Error('offline')) + const props = mount(true, en, signOut) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) + expect(screen.getByRole('alert').textContent).toBe(en.failed) + expect(props.close).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(props.close).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: en.signOut }).hasAttribute('disabled')).toBe(true) + await act(async () => { pending.resolve(undefined) }) + expect(props.close).toHaveBeenCalledOnce() +}) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 454baf309b..5d0264c3ce 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -51,6 +51,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 28d7b1027d..09a7b5bbcc 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -100,6 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 8a503e803f..25942ade6f 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 7c3073aefb90fa2ec74d521d6c9d97aff5f74ace -README.zh.md: 44621a0b02eadfc574f6f6b924f64cfeb4141cd3 +README.md: e58f05e619fc5a39d26edd71ed92c79fe04dcc81 +README.zh.md: 490a4a1849daee182357f6eaadc44a5339b060ca diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 7c3073aefb..e58f05e619 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## Use this package +The running phase retains the final prepared provider through tool execution and retry preparation, resets it before publishing `turn/end`, and exposes it through `Agent.activeProvider`. Plugins can cancel matching live agents without consulting mutable settings or historical request headers. + Mount `dsh-agent-loop` in any composition that should run agents. It supplies the driver behind `ctx.agents` and starts any agents you declare in its config; both [`dsh-base`](../../bundle/base/README.md) and [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.md) mount it as an explicit row. ### Configure declarative agents diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 44621a0b02..490a4a1849 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +运行阶段在工具执行及重试准备期间保留最终准备完成的提供方,在发布 `turn/end` 前重置,并通过 `Agent.activeProvider` 暴露。插件可以取消匹配的运行中代理,无须读取可变设置或历史请求头。 + 在任何应运行 agent 的组合中挂载 `dsh-agent-loop`。它提供 `ctx.agents` 背后的驱动器,并启动你在配置中声明的 agent;[`dsh-base`](../../bundle/base/README.zh.md) 与 [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.zh.md) 都将它作为显式配置行挂载。 ### 配置声明式 agent diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4ea7593551..f44e1aa024 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -46,7 +46,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean; activeProvider?: string } type StepEndReason = Extract @@ -115,6 +115,10 @@ export class ReactLoopAgent implements Agent { return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } + get activeProvider(): string | undefined { + return this.phase.kind === 'running' ? this.phase.activeProvider : undefined + } + /** Commit a phase and publish its externally visible status transition. */ private setPhase(next: Phase): void { const previousStatus = this.status @@ -336,6 +340,7 @@ export class ReactLoopAgent implements Agent { this.throwError(error) } finally { try { + delete phase.activeProvider // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending this.session.append('turn/end', { turn, reason: turnEnds! }) } catch (error: unknown) { @@ -547,6 +552,7 @@ export class ReactLoopAgent implements Agent { config = proposedConfig } signal.throwIfAborted() + if (this.phase.kind === 'running') this.phase.activeProvider = config.provider return { config, ...preparedCall === undefined ? {} : { preparedCall } } } diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 739d18b990..f4a9eeb387 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -26,6 +26,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 59f4628536..26ee824e52 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 8ba3dc730790acb0f792173e9ee01d2dc954957e -README.zh.md: 0718ae5f3f73b1fd0bb5b600de3a08c50e67421e +README.md: a07145131186fddb30de62536f3001a36b32dc04 +README.zh.md: d21c010e418c997807b3eec36a585a8b84bea31d diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8ba3dc7307..a071451311 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,6 +25,8 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package +The read-only `Agent.activeProvider` identifies the current turn’s latest prepared request. It is absent before the first request is prepared and between turns; tools and retries retain the last provider until another request finishes preparation. Configuration changes alone do not reclassify in-flight work. + Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. ### Create or resume an agent diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 0718ae5f3f..d21c010e41 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +只读 `Agent.activeProvider` 标识当前轮次最近一次完成准备的请求提供方。首请求准备完成前及轮次之间没有该值;工具执行和重试期间保留上一提供方,直到另一请求完成准备。仅改变配置不会改变在途任务的归属。 + 在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 ### 创建或恢复 agent diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 2118507b8c..bb87e7986e 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -170,6 +170,8 @@ declare module './types.ts' { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ + readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c31acf5c66..99f20f2419 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -24,6 +24,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { inbox: { nextTurn: [], nextStep: [], } as never, + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 7315a4b6c1..b896927357 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: 248f679dd7b27af35eafa59579df7538db213b23 -README.zh.md: 36c3f861d60f2c02cca76feabed85862630ead98 +README.md: 31172b1749428decebed41fac99e3fc34a70cf6d +README.zh.md: bdff2428021b7452865cb10d6774fff618c3b111 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 248f679dd7..31172b1749 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -28,6 +28,8 @@ Sign in through the system browser and keep the account credential in the existi The profile projection maps `id_profile.picture` to `avatarUrl`, using null when no picture is configured. +Token resolution refuses requests while local sign-out is in progress. Successful local removal publishes `deepseek-account/signed-out` before returning the signed-out snapshot; remote revocation remains independent. + getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, avatar URL, phone or email exactly as masked by Platform, and normal_wallets / bonus_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries. Configure platformOrigin, allowLoopbackHttp, requestTimeoutMs, and attemptTimeoutMs in the plugin row. HTTP is accepted only for explicitly enabled loopback development. The provider registers /oauth/callback on the existing Host webServer before auth_init, validates state, exchanges an S256 PKCE code once, and commits a grant before redirecting to auth_exchange.biz_data.authorized_url. Browser destinations default to the configured platform origin and always require the fixed /dsh/authorize or /dsh/authorized path. The completion URL sets `login_source` to the initiating client (`web` or `desktop`) and preserves other Platform-supplied query parameters. Device identity is a separate random UUID record shared by processes using the same credential store; device_model reports OS and architecture. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 36c3f861d6..bdff242802 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -26,6 +26,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 ## 使用此包 +本地退出登录期间拒绝解析 token。本地授权成功删除后,先发布 `deepseek-account/signed-out`,再返回已退出的状态快照;远程撤销独立执行。 + 账号资料投影将 `id_profile.picture` 映射为 `avatarUrl`,没有配置头像时返回 null。 getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、头像 URL、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets / bonus_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index bea62a3558..835b79d7da 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -222,6 +222,7 @@ export class PlatformAccount extends DeepSeekAccount { } override async resolveToken(url: string): Promise { + if (this.closed || this.removing !== undefined) return undefined const destination = new URL(url) if (destination.origin !== this.inferenceOrigin || destination.username || destination.password) return undefined const record = await this.ctx.credentials.readRecord(KEY) @@ -311,6 +312,7 @@ export class PlatformAccount extends DeepSeekAccount { this.revoke(parsed.data.token) } this.attempt = undefined + this.ctx.emit('deepseek-account/signed-out') this.changed() return this.getState() })().finally(() => { this.removing = undefined }) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 407a56f89b..d04dc498ce 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -329,6 +329,18 @@ it('retains profile data when balance fails instead of reporting a zero balance' expect(await readDetails(f.account)).toMatchObject({ profile: { status: 'ready' }, balance: { status: 'failed' } }) }) +it('publishes sign-out only after local grant removal and refuses token resolution while removing', async () => { + const f = await fixture() + await storeAccount(f) + const observed: string[] = [] + f.ctx.on('deepseek-account/signed-out', () => { observed.push('signed-out') }) + const pending = f.account.signOut() + expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined() + await pending + expect(observed).toEqual(['signed-out']) + expect((await f.account.getState()).status).toBe('signed-out') +}) + it('discards account details when sign-out races the Platform response', async () => { const f = await fixture() await storeAccount(f) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 7d8ddfe1f3..4841809a90 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md -README.md: 72b42ee996b809992e48ea4c124f0793d756526a -README.zh.md: b30813e24adc2afc9e8245b839432dd195093c63 +README.md: 52bc8e15ee72282b79c0b8823b56476650cdf728 +README.zh.md: 427b4a22aaf1ed503d41e90d0db600d220ef931e diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 72b42ee996..52bc8e15ee 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -26,6 +26,8 @@ Account consumers read stored login state, start or cancel a browser login, and `AccountProfile.avatarUrl` is an optional profile image URL; null or absence means no avatar. +Successful local sign-out emits `deepseek-account/signed-out`. Consumers own any cancellation policy; the account service does not track agents or model providers. + The service defines account operations and reconnectable state snapshots. The platform provider owns the protocol and stored grant. Credentials are Host-only; the API controller exports state and commands without resolveToken. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index b30813e24a..427b4a22aa 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -24,6 +24,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token ## 使用此包 +本地成功退出登录后发出 `deepseek-account/signed-out`。消费方负责取消策略;账号服务不跟踪 agent 或模型提供方。 + `AccountProfile.avatarUrl` 是可选的账号头像 URL;为空或缺失时表示没有头像。 该服务定义账号操作和可重连的状态快照。平台提供者负责协议和授权记录。凭证仅限 Host;API 控制器只导出状态与操作,不导出 resolveToken。 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 465bbedf03..804dc512f6 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -4,6 +4,12 @@ import type { AccountDetails, AccountView, SignInAttemptId } from './types.ts' export type { AccountDetails, AccountProfile, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts' declare module '@deepseek-ai/cordis' { + interface Events { + /** Local grant removal has completed. + * @mode emit + */ + 'deepseek-account/signed-out'(): void + } interface Context { deepseekAccount: DeepSeekAccount } @@ -53,7 +59,7 @@ export abstract class DeepSeekAccount extends Service { */ abstract cancelSignIn(id: SignInAttemptId): Promise /** - * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background. + * Remove the local grant while retaining API keys; the provider revokes it in the background. * @returns the signed-out state after local removal; remote failures never restore the grant. */ abstract signOut(): Promise diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 6a603163bd..947d5acedc 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -35,6 +35,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', get ctx() { return scope.ctx }, send: () => {}, diff --git a/packages/experimental/browser-use-runtime/tests/resources.spec.ts b/packages/experimental/browser-use-runtime/tests/resources.spec.ts index f838191d44..104489f420 100644 --- a/packages/experimental/browser-use-runtime/tests/resources.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/resources.spec.ts @@ -16,7 +16,7 @@ async function fixture() { const fiber = ctx.plugin(() => {}) const session = Session.create(SessionId(id)) const agent: Agent = { - id: session.id, session, ctx: fiber.ctx, options: {}, status: 'idle', + id: session.id, session, ctx: fiber.ctx, options: {}, activeProvider: undefined, status: 'idle', inbox: unsupportedInbox(), send() {}, followup() {}, inject() {}, cancel() {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), runMaintenance: task => task(new AbortController().signal), diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a56e94240d..9e8c680d18 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -895,7 +895,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract signOut(): Promise', - description: 'Remove the local grant while retaining API keys and tasks; the provider revokes it in the background.', + description: 'Remove the local grant while retaining API keys; the provider revokes it in the background.', parameters: [], returns: 'the signed-out state after local removal; remote failures never restore the grant.', }, @@ -3744,6 +3744,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation\'s outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.', parameters: [{ name: 'ref', description: 'the reference whose stored value changed.' }], }, + { + name: 'deepseek-account/signed-out', + mode: 'emit', + signature: '\'deepseek-account/signed-out\'(): void', + summary: 'Local grant removal has completed.', + description: 'Local grant removal has completed.', + parameters: [], + }, { name: 'domain/changed', mode: 'emit', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 4e5401c09e..7db9617a08 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -38,6 +38,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox: unsupportedInbox(), ctx: new Context(), + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 273657e579..ee442e64f0 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -37,6 +37,7 @@ async function agent(ctx: Context): Promise { session, inbox: unsupportedInbox(), ctx: scope.ctx, + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 1f893f37fe..6abb17030f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -38,6 +38,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1e4e65c7a1..3ad29e5b3b 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox, ctx: new Context(), + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 06e31d7196..68f71918ab 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -59,6 +59,7 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent session, inbox, ctx: agentCtx, + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 227da38ad8..9bc7ae6693 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -44,6 +44,7 @@ async function liveAgent(ctx: Context, session: Session): Promise { session, inbox: unsupportedInbox(), ctx, + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..9e606e4bb1 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -58,6 +58,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St options: {}, session, inbox, + activeProvider: undefined, get status() { return status }, ctx: agentCtx, send: () => {}, diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index dafd91844f..1e0c300f8f 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -36,6 +36,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle' as const, ctx: agentCtx, send: () => {}, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 9445acaf60..11c68612d8 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 5a3a3a4ff1cf3c30e8c44f079dc90069654efeac -README.zh.md: d58364c0ac21bd67d21761fc732bf9d34dfac802 +README.md: abd29fbdc49a100fa3e5a92ad4db05fc487ef456 +README.zh.md: d955dc40190a2b00d9034859c99c004ba06a4fa3 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a3a3a4ff1..abd29fbdc4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -234,6 +234,6 @@ This Dev Note is non-authoritative working context: undecided directions and not **Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. -When the account provider is mounted, its stored token takes priority only for https://api.deepseek.com. Other origins and signed-out accounts retain the configured API-key reference. Chat and Files requests reject redirects. Signing out removes only the account grant and preserves API keys. +`deepseek-official` uses only its configured API-key reference; `deepseek-account` uses only the stored DSH grant for the account provider’s allowed inference origin. Both routes share protocol, model, and file settings. Missing or ineligible account credentials reject the request with a sign-in prompt; neither route falls back to the other. Chat and Files requests reject redirects. Successful sign-out cancels live agents whose current prepared provider is `deepseek-account`, including tool execution, while preserving pending inbox messages and independent API-key tasks. DSH account tokens use x-dsh-auth-token without a Bearer prefix for both inference protocols and all Files operations. API keys retain each protocol’s existing authentication header. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d58364c0ac..d955dc4019 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -234,6 +234,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 -挂载账号提供者时,其已保存 token 仅对 https://api.deepseek.com 优先生效。其他来源和已退出的账号继续使用配置的 API Key 引用。聊天和文件请求拒绝重定向。退出登录只删除账号授权记录,保留 API Key。 +`deepseek-official` 仅使用配置的 API Key 引用;`deepseek-account` 仅在账号提供方允许的推理来源使用已保存的 DSH 授权。两条路由共享协议、模型和文件设置。账号凭证缺失或不适用于目标时拒绝请求并提示登录;两条路由均不回退到另一凭证。Chat 和 Files 请求拒绝重定向。退登成功会取消当前已准备提供方为 `deepseek-account` 的运行代理,包括工具执行阶段,同时保留待处理消息和独立的 API Key 任务。 DSH 账号 token 在两种推理协议和所有 Files 操作中使用 x-dsh-auth-token,不加 Bearer 前缀。API Key 保持各协议现有的认证请求头。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 9f9d194442..5b031e3e5d 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^" + "@deepseek-ai/dsh-deepseek-account": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -79,6 +80,9 @@ "peerDependenciesMeta": { "@deepseek-ai/dsh-deepseek-account": { "optional": true + }, + "@deepseek-ai/dsh-agent": { + "optional": true } } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 722f69bc3f..c25125987e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -23,7 +23,7 @@ export class DeepSeekAdapter extends LlmAdapter { return new DeepSeekMessagesAdapter({ connection: () => connection, apiKey: this.dependencies.resolveApiKey, - ...this.dependencies.resolveAccountToken === undefined ? {} : { accountToken: this.dependencies.resolveAccountToken }, + accountCredential: this.dependencies.accountCredential === true, userId: this.dependencies.resolveUserId, attachments: () => this.dependencies.resolveAttachments?.(), imageAccess: (ref) => { @@ -41,7 +41,7 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return this.implementation().providerInfo(provider) } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek (Account)' : 'DeepSeek (API Key)' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } override listModels(provider: string) { return this.implementation().listModels(provider) } override resolveModel(provider: string, model: string, signal?: AbortSignal) { diff --git a/packages/llm/llm-deepseek/src/common/types.ts b/packages/llm/llm-deepseek/src/common/types.ts index 21fe8f37a7..07072e92e7 100644 --- a/packages/llm/llm-deepseek/src/common/types.ts +++ b/packages/llm/llm-deepseek/src/common/types.ts @@ -94,14 +94,14 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for the connection facts of one request. The + * Resolve this adapter’s credential for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` - * `MISSING_CREDENTIAL` when no key is available anywhere. + * `MISSING_CREDENTIAL` for a missing API key; account routes reject unavailable grants. */ resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise - /** Resolve a DSH account token only for an eligible official endpoint. */ - resolveAccountToken?: (connection: DeepSeekConnectionOptions) => Promise + /** Send the resolved credential as a bare DSH account token; never fall back to an API key. */ + accountCredential?: boolean /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6cf7922f6c..c740a9e449 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,5 +1,6 @@ /** Register DeepSeek with protocol selection and request-local settings and credentials. */ import type {} from '@deepseek-ai/dsh-deepseek-account' +import type {} from '@deepseek-ai/dsh-agent' import type { Context } from '@deepseek-ai/cordis' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' @@ -107,13 +108,17 @@ export function apply(ctx: Context, config: Config): void { let userId: AnonymousUserId | undefined const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = new DeepSeekAdapter({ + const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ + accountCredential, options, onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) }, - resolveApiKey, - resolveAccountToken: connection => ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) ?? Promise.resolve(undefined), + resolveApiKey: accountCredential ? async (connection) => { + const token = await ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) + if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') + return token + } : resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( @@ -128,11 +133,21 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. - const registration = ctx.llm.registerAdapter([PROVIDER], adapter) + const registration = ctx.llm.registerAdapter([PROVIDER], adapter(false)) + const accountRegistration = ctx.llm.registerAdapter(['deepseek-account'], adapter(true)) + ctx.inject(['agents'], (agentCtx) => { + agentCtx.on('deepseek-account/signed-out', () => { + for (const agent of agentCtx.agents.list()) { + if (agent.activeProvider === 'deepseek-account') { + agent.cancel({ kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }) + } + } + }) + }) let registeredPolicy = options().retryPolicy const ensureRegistrationFacts = (): void => { const policy = options().retryPolicy @@ -143,6 +158,7 @@ export function apply(ctx: Context, config: Config): void { // publish an empty route set between the two, and an observer that reacted // to it would see this provider disappear and come back. registration.replace([PROVIDER]) + accountRegistration.replace(['deepseek-account']) registeredPolicy = policy } diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts index 6e32d4d703..78b7810dca 100644 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts +++ b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts @@ -193,8 +193,7 @@ export class ChatCompletionsAdapter extends LlmAdapter { ) } } - const accountToken = await this.config.resolveAccountToken?.(connection) - const apiKey = accountToken ?? await this.config.resolveApiKey(connection) + const apiKey = await this.config.resolveApiKey(connection) const userId = this.config.resolveUserId() const consumer = new AbortController() const upstream = options.signal === undefined @@ -206,7 +205,7 @@ export class ChatCompletionsAdapter extends LlmAdapter { watchdog.signal, connection, apiKey, - accountToken !== undefined, + this.config.accountCredential === true, userId, attachments, () => { watchdog.pulse() }, diff --git a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts index f669be7ed3..ade2e75d2f 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts +++ b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts @@ -24,7 +24,7 @@ export interface AdapterDependencies { /** Resolve the key named by that same generation. */ apiKey(connection: Connection): Promise /** DSH account token for an eligible official endpoint. */ - accountToken?(connection: Connection): Promise + accountCredential?: boolean /** Stable anonymous Harness identity. */ userId(): string /** Current attachment service; absence is valid for text requests. */ @@ -94,10 +94,9 @@ export class DeepSeekMessagesAdapter extends LlmAdapter { const { messages, versions } = await prepareImages( options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal, ) - const accountToken = await this.dependencies.accountToken?.(connection) - const key = accountToken ?? await this.dependencies.apiKey(connection) + const key = await this.dependencies.apiKey(connection) const files = new RequestFiles(this.dependencies.files(), { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, protocol: 'messages', + baseURL: connection.baseURL, apiKey: key, accountCredential: this.dependencies.accountCredential === true, protocol: 'messages', }, connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) let inline = false @@ -129,7 +128,7 @@ export class DeepSeekMessagesAdapter extends LlmAdapter { headers: { ...attributionHeaders(), 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + ...this.dependencies.accountCredential === true ? { 'x-dsh-auth-token': key } : { 'x-api-key': key }, 'anthropic-version': '2023-06-01', ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, 'x-deepseek-harness-user-id': this.dependencies.userId(), diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts new file mode 100644 index 0000000000..64b050fd02 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -0,0 +1,216 @@ +/** Account route cancellation uses the real loop, registry, and tool lifecycle. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk, ToolCallId } from '@deepseek-ai/dsh-llm' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import * as DeepSeek from '../src/index.ts' +import { assemble } from './assemble.ts' + +const contexts: Context[] = [] +afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +async function harness() { + const ctx = new Context() + contexts.push(ctx) + for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) { + await ctx.plugin(plugin) + } + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(DeepSeek, { protocol: 'chat-completions' }) + return ctx +} + +function send(agent: Agent) { + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) +} + +class ScriptedAdapter extends LlmAdapter { + constructor(private run: (request: GenerateOptions) => AsyncIterable) { super() } + override stream(request: GenerateOptions) { return this.run(request) } +} + +it.each(['chat-completions', 'messages'] as const)('never falls back to an API key while signed out: %s', async (protocol) => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeek, { protocol }) + const fetch = vi.spyOn(globalThis, 'fetch') + const result = await assemble(ctx, { provider: 'deepseek-account', model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } }) + expect(fetch).not.toHaveBeenCalled() +}) + +it.each(['deepseek-account', 'deepseek-official'])('cancels only the active account route during tools: %s', async (provider) => { + const ctx = await harness() + const toolEntered = Promise.withResolvers() + const release = Promise.withResolvers() + const adapter = new ScriptedAdapter(async function* () { + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('fixture-call'), name: 'wait', arguments: '{}' } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + }) + ctx.on('llm/stream', request => adapter.stream(request)) + const agent = await ctx.agentLoop.create(SessionId(`tools-${provider}`), { provider, model: 'deepseek-v4-flash' }) + ctx.tools.register(defineContentToolFixture({ name: 'wait', description: '', parameters: {}, async execute() { + toolEntered.resolve(undefined) + await release.promise + return [{ type: 'text', text: 'done' }] + } })) + send(agent) + await toolEntered.promise + expect(agent.activeProvider).toBe(provider) + agent.inbox.splice('next-turn', Infinity, 0, [createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })]) + ctx.emit('deepseek-account/signed-out') + // Changing settings cannot reclassify the already running tool call. + expect(agent.activeProvider).toBe(provider) + expect(agent.inbox.nextTurn).toHaveLength(1) + if (provider === 'deepseek-official') agent.cancel({ kind: 'user' }, { keepInbox: true }) + release.resolve(undefined) + await agent.whenIdle() + expect(agent.activeProvider).toBeUndefined() + expect(agent.inbox.nextTurn).toHaveLength(1) + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: provider === 'deepseek-account' + ? { kind: 'hook', reason: 'deepseek-account/signed-out' } : { kind: 'user' } } }) +}) + +it.each(['tool', 'retry'])('replaces the active provider only after the next request binds: %s', async (transition) => { + const ctx = await harness() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + const streaming = Promise.withResolvers() + let requests = 0 + ctx.on('agent/request', async (_event, next) => { + const config = await next() + if (++requests === 1) return config + preparing.resolve(undefined) + await proceed.promise + return { ...config, provider: 'deepseek-official' } + }) + ctx.on('agent/request-error', async (_event, next) => { await next(); return { kind: 'retry' } }) + ctx.on('llm/stream', async function* (request) { + if (request.provider === 'deepseek-account') { + if (transition === 'retry') { + yield { type: 'finish', reason: { kind: 'error', failure: { code: 'TRANSPORT', message: 'retry fixture' } } } + return + } + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('switch-call'), name: 'continue', arguments: '{}' } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + const signal = request.signal! + streaming.resolve(signal) + await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + signal.throwIfAborted() + }) + ctx.tools.register(defineContentToolFixture({ name: 'continue', description: '', parameters: {}, execute: async () => [{ type: 'text', text: 'done' }] })) + const agent = await ctx.agentLoop.create(SessionId('switch-provider'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + expect(agent.activeProvider).toBeUndefined() + send(agent) + await preparing.promise + expect(agent.activeProvider).toBe('deepseek-account') + proceed.resolve(undefined) + const signal = await streaming.promise + expect(agent.activeProvider).toBe('deepseek-official') + ctx.emit('deepseek-account/signed-out') + expect(signal.aborted).toBe(false) + agent.cancel({ kind: 'user' }) + await agent.whenIdle() + expect(agent.activeProvider).toBeUndefined() +}) + +it('does not infer a provider while the first request is still being prepared', async () => { + const ctx = await harness() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + ctx.on('agent/request', async ({ signal }, next) => { + preparing.resolve(undefined) + await proceed.promise + expect(signal.aborted).toBe(false) + return next() + }) + const agent = await ctx.agentLoop.create(SessionId('first-preparation'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + await preparing.promise + expect(agent.activeProvider).toBeUndefined() + ctx.emit('deepseek-account/signed-out') + proceed.resolve(undefined) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'error', error: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } } }) +}) + + +it.each(['chat-completions', 'messages'] as const)('never borrows an account token for a missing API key: %s', async (protocol) => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + const resolveToken = vi.fn(async () => 'fixture-account-token') + ctx.provide('deepseekAccount', { resolveToken } as unknown as DeepSeekAccount) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeek, { protocol }) + const fetch = vi.spyOn(globalThis, 'fetch') + const result = await assemble(ctx, { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + expect(resolveToken).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + + +it('propagates account sign-out to the HTTP request signal', async () => { + const ctx = await harness() + ctx.provide('deepseekAccount', { resolveToken: async () => 'fixture-account-token' } as unknown as DeepSeekAccount) + const started = Promise.withResolvers() + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const signal = init!.signal! + expect(new Headers(init!.headers).get('x-dsh-auth-token')).toBe('fixture-account-token') + started.resolve(signal) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('request aborted', { cause: signal.reason })) }, { once: true }) + }) + }) + const agent = await ctx.agentLoop.create(SessionId('http-abort'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + const signal = await started.promise + ctx.emit('deepseek-account/signed-out') + await agent.whenIdle() + expect(signal.aborted).toBe(true) + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } } }) +}) + + +it('cancels an account child without stopping its API-key parent', async () => { + const ctx = await harness() + const parentStarted = Promise.withResolvers() + const childStarted = Promise.withResolvers() + ctx.on('llm/stream', async function* (request) { + const signal = request.signal! + if (request.provider === 'deepseek-account') childStarted.resolve(signal) + else parentStarted.resolve(signal) + yield { type: 'block-start', index: 0, blockType: 'text' } + await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + signal.throwIfAborted() + }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child'), parentAgent: parent, + agentOptions: { provider: 'deepseek-account', model: 'deepseek-v4-flash' } }) + send(parent) + send(childHandle.agent) + const [parentSignal, childSignal] = await Promise.all([parentStarted.promise, childStarted.promise]) + ctx.emit('deepseek-account/signed-out') + await childHandle.agent.whenIdle() + expect(childSignal.aborted).toBe(true) + expect(parentSignal.aborted).toBe(false) + parent.cancel({ kind: 'user' }) + await parent.whenIdle() +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 77b0beb741..2c95e5db00 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,10 +1697,10 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', - displayName: 'DeepSeek', + displayName: 'DeepSeek (API Key)', settingsNs: 'llm-deepseek', settingsPath: [], }]) @@ -1733,7 +1733,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2186,7 +2186,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) }) it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { @@ -2196,7 +2196,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) // First-boot onboarding: the route registers so models stay discoverable; // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2277,7 +2277,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { @@ -2360,11 +2360,11 @@ describe('plugin registration and config', () => { it.each([ - ['chat-completions', 'https://api.deepseek.com', 'account-token'], - ['messages', 'https://api.deepseek.com', 'account-token'], - ['chat-completions', 'https://custom.example.test', 'ambient-key'], - ['messages', 'https://custom.example.test', 'ambient-key'], -] as const)('selects account or API-key credentials from the actual endpoint %s', async (protocol, baseURL, expected) => { + ['chat-completions', 'deepseek-account', 'account-token'], + ['messages', 'deepseek-account', 'account-token'], + ['chat-completions', 'deepseek-official', 'ambient-key'], + ['messages', 'deepseek-official', 'ambient-key'], +] as const)('keeps provider credentials independent for %s %s', async (protocol, provider, expected) => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const ctx = new Context() // This consumer uses only resolveToken; the real provider owns origin validation in its own suite. @@ -2372,7 +2372,7 @@ it.each([ resolveToken: (url: string) => Promise.resolve(url === 'https://api.deepseek.com' ? 'account-token' : undefined), } as DeepSeekAccount) await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { baseURL, protocol }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'https://api.deepseek.com', protocol }) const request = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { const headers = new Headers(init?.headers) expect(headers.get('authorization')).toBe(expected === 'account-token' || protocol === 'messages' ? null : `Bearer ${expected}`) @@ -2382,7 +2382,7 @@ it.each([ return Promise.resolve(new Response('fixture rejection', { status: 400 })) }) try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { provider, model: 'deepseek-v4-flash', messages: [] }) expect(request).toHaveBeenCalledOnce() - } finally { request.mockRestore() } + } finally { request.mockRestore(); await ctx.fiber.dispose() } }) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index b1bae1c4bc..e366466ab9 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -58,6 +58,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../core/agent" } ] } diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index d86859ac50..22eba5d3be 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -63,6 +63,7 @@ async function harness(): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index a34281c1b5..c7775725ba 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, id: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index ea3688fefb..a520878b48 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,6 +49,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index a79d116556..5c114c1c81 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -43,6 +43,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index eacd51d586..22f9951599 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -56,6 +56,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index c260f818eb..b7cb58ccac 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -43,6 +43,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7d9fb0d2f0..15528c1074 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -73,6 +73,7 @@ function agentForCwd(cwd: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, @@ -90,6 +91,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 16b7f9ffce..9904d11c8e 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -56,6 +56,7 @@ function agent(ctx: Context, cwd?: string): Agent { }) return { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, @@ -658,6 +659,7 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -708,6 +710,7 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 7ef28c6929..31a870becd 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -41,6 +41,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 11da341945..d43654b997 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index b983872934..8279699981 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -44,6 +44,7 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 934b4c6d60..e106a8c50a 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -20,6 +20,7 @@ async function fakeAgent(ctx: Context, rawId: string): Promise { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index a1e76b01b3..5ec74013fe 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -35,6 +35,7 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81dfd23e8a..7778620938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -948,6 +948,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-deepseek-account': specifier: workspace:^ version: link:../../credentials/deepseek-account diff --git a/python/sdk/tests/expected/account-provider-signout.json b/python/sdk/tests/expected/account-provider-signout.json new file mode 100644 index 0000000000..64969395aa --- /dev/null +++ b/python/sdk/tests/expected/account-provider-signout.json @@ -0,0 +1,11 @@ +{ + "final_response": "Before sign-out", + "finish_reason": "aborted", + "reason": { + "kind": "aborted", + "reason": { + "kind": "hook", + "reason": "deepseek-account/signed-out" + } + } +} diff --git a/python/sdk/tests/test_account_provider_snapshot.py b/python/sdk/tests/test_account_provider_snapshot.py new file mode 100644 index 0000000000..5d27eb5522 --- /dev/null +++ b/python/sdk/tests/test_account_provider_snapshot.py @@ -0,0 +1,35 @@ +"""Project the recorded account cancellation through the real Python SDK.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness + +ROOT = Path(__file__).resolve().parents[3] +CLI = ROOT / 'apps' / 'cli' / 'lib' / 'bin.js' + + +@pytest.mark.skipif(not CLI.exists(), reason='requires the built dsh profile runtime') +def test_account_provider_signout_snapshot(tmp_path: Path) -> None: + with DeepSeekHarness( + dsh_bin=str(CLI), + dsh_home=str(tmp_path / 'home'), + cwd=str(tmp_path), + profile='sdk', + patches=(str(ROOT / 'snapshots' / 'sdk' / 'account-provider-signout' / 'cordis.yml'),), + provider='deepseek-account', + model='deepseek-v4-flash', + env={'DSH_TELEMETRY_DISABLED': '1', 'DSH_SNAPSHOT': 'replay'}, + ) as harness: + result = harness.run('Reply with exactly: SDK snapshot') + ending = next(event for event in reversed(result.events) if event['type'] == 'turn/end') + actual = { + 'final_response': result.final_response, + 'finish_reason': result.finish_reason, + 'reason': ending['data']['reason'], + } + expected = Path(__file__).with_name('expected') / 'account-provider-signout.json' + assert actual == json.loads(expected.read_text()) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e2adb7f7e0..838cf6d998 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -216,6 +216,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'cordis': 'extensions.md', 'authorization': 'credentials.md', 'credentials': 'credentials.md', + 'deepseek-account': 'credentials.md', 'domain': 'storage.md', 'fs': 'filesystem.md', 'goal': 'goal.md', diff --git a/snapshots/sdk/account-provider-signout/account-signout.mjs b/snapshots/sdk/account-provider-signout/account-signout.mjs new file mode 100644 index 0000000000..c468f6022b --- /dev/null +++ b/snapshots/sdk/account-provider-signout/account-signout.mjs @@ -0,0 +1,11 @@ +/** Deterministic transport boundary; cancellation belongs to the shipped DeepSeek plugin. */ +export const inject = ['llm', 'agents'] +export function apply(ctx) { + ctx.on('llm/stream', async function* (request, next) { + if (request.provider !== 'deepseek-account') { yield* next(); return } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'Before sign-out' } + ctx.emit('deepseek-account/signed-out') + request.signal.throwIfAborted() + }) +} diff --git a/snapshots/sdk/account-provider-signout/cordis.snapshot.yml b/snapshots/sdk/account-provider-signout/cordis.snapshot.yml new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/cordis.snapshot.yml @@ -0,0 +1 @@ +[] diff --git a/snapshots/sdk/account-provider-signout/cordis.yml b/snapshots/sdk/account-provider-signout/cordis.yml new file mode 100644 index 0000000000..dc92a814c0 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/cordis.yml @@ -0,0 +1,33 @@ +# TypeScript SDK snapshot-record patch over `dsh --profile sdk`. + +# The fixture corpus must not depend on the developer's personal skill roots. +# Ordinary product launches retain the sdk profile's default skill discovery. +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + includeDefaultRoots: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + enableRunInBackground: false + maxDepth: 1 + +- insert: + - id: account-signout-fixture + name: './account-signout.mjs' diff --git a/snapshots/sdk/account-provider-signout/notifications.expected.jsonl b/snapshots/sdk/account-provider-signout/notifications.expected.jsonl new file mode 100644 index 0000000000..58848035b2 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/notifications.expected.jsonl @@ -0,0 +1,15 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-account","model":"deepseek-v4-flash","contextWindow":1000000}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[8],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Before sign-out"}],"source":{"kind":"model","provider":"deepseek-account","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true,"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Before sign-out"]}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"hook","reason":"deepseek-account/signed-out"}}}}}} +{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/account-provider-signout/result.expected.json b/snapshots/sdk/account-provider-signout/result.expected.json new file mode 100644 index 0000000000..126859b4d3 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/result.expected.json @@ -0,0 +1 @@ +{"sessionId":"{{sessionId}}","finalResponse":"Before sign-out"} diff --git a/snapshots/sdk/account-provider-signout/session.v3.jsonl b/snapshots/sdk/account-provider-signout/session.v3.jsonl new file mode 100644 index 0000000000..c4914849ec --- /dev/null +++ b/snapshots/sdk/account-provider-signout/session.v3.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1785097381464,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-account","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Before sign-out"}],"source":{"kind":"model","provider":"deepseek-account","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"interrupted":true,"stream":[{"type":"chunk","time":1789643933994,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789643933994,"index":0,"dt":[],"texts":["Before sign-out"]}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"hook","reason":"deepseek-account/signed-out"}}}} diff --git a/snapshots/sdk/account-provider-signout/snapshot.yml b/snapshots/sdk/account-provider-signout/snapshot.yml new file mode 100644 index 0000000000..b5300fde63 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: account-provider-signout +profile: sdk +composition: sdk-account-provider-signout +recording: authored +header: + class: sdk-account-provider-signout + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn diff --git a/snapshots/sdk/account-provider-signout/system-prompt.expected.md b/snapshots/sdk/account-provider-signout/system-prompt.expected.md new file mode 120000 index 0000000000..4ca0dd39b2 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/system-prompt.expected.md @@ -0,0 +1 @@ +../text-turn/system-prompt.expected.md \ No newline at end of file diff --git a/snapshots/sdk/account-provider-signout/tool-schemas.expected.json b/snapshots/sdk/account-provider-signout/tool-schemas.expected.json new file mode 120000 index 0000000000..11d7ccbf24 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/tool-schemas.expected.json @@ -0,0 +1 @@ +../text-turn/tool-schemas.expected.json \ No newline at end of file From cc478ac70a93806d28251b9450b234b79bd56bf0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:59:31 +0800 Subject: [PATCH 012/253] feat(client,session-controller,llm): track available models and expose account settings --- apps/web/tests/default-model.e2e.ts | 15 +++- .../default-model/unselected.expected.md | 8 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 9 ++- docs/subsystems/core.zh.md | 9 ++- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 7 ++ docs/subsystems/session.zh.md | 7 ++ packages/api/remotes/src/remote-events.ts | 1 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../api/session-controller/src/catalog.ts | 14 +++- .../api/session-controller/src/commands.ts | 22 ++--- packages/api/session-controller/src/index.ts | 17 +++- packages/api/session-controller/src/types.ts | 2 +- .../tests/commands-upload-file.host.spec.ts | 1 + .../tests/session-cold.host.spec.ts | 1 + .../tests/session-models.host.spec.ts | 81 ++++++++++++------- .../session-controller/tests/test-remote.ts | 4 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationContent.tsx | 4 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 8 +- .../client/ui-model-selection/README.zh.md | 8 +- .../src/client/directory.ts | 29 ++----- .../ui-model-selection/src/client/locales.ts | 4 +- .../ui-model-selection/src/client/service.ts | 8 +- .../tests/browser-plugin.client.spec.ts | 40 ++++----- .../tests/expected/unselected-model.txt | 1 + .../tests/model-select.client.spec.tsx | 9 +++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 3 + .../client/ui-settings-account/README.zh.md | 3 + .../ui-settings-account/src/client/index.ts | 14 +++- .../tests/apply.client.spec.ts | 55 +++++++++++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/CustomProviderCard.tsx | 2 +- .../src/client/ModelsSection.tsx | 6 +- .../src/client/ProviderEditor.tsx | 9 ++- .../ui-settings-models/src/client/index.ts | 3 +- .../ui-settings-models/src/client/locales.ts | 2 + .../src/client/operations.ts | 6 ++ .../ui-settings-models/src/client/store.ts | 15 +++- .../tests/apply.client.spec.ts | 3 +- .../tests/components.client.spec.tsx | 23 +++++- .../tests/expected/deepseek-account-en.txt | 1 + .../tests/expected/deepseek-account-zh.txt | 1 + .../tests/model-list-catalog.client.spec.tsx | 1 + .../tests/onboarding-dialog.client.spec.tsx | 2 +- .../tests/provider-form.client.spec.tsx | 4 +- .../tests/store.client.spec.ts | 18 ++++- .../core/agent-default-model/README.i18n.yaml | 4 +- packages/core/agent-default-model/README.md | 6 +- .../core/agent-default-model/README.zh.md | 6 +- .../core/agent-default-model/src/index.ts | 39 +++++++-- .../tests/agent-default-model.spec.ts | 23 ++++-- .../extensions/tool-cordis/src/api-catalog.ts | 14 +++- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 + packages/llm/llm-deepseek/README.zh.md | 2 + packages/llm/llm-deepseek/src/adapter.ts | 13 ++- packages/llm/llm-deepseek/src/index.ts | 1 + .../tests/account-routing.spec.ts | 17 ++++ .../llm/llm-deepseek/tests/adapter.spec.ts | 22 ++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 9 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 77 files changed, 511 insertions(+), 189 deletions(-) create mode 100644 apps/web/tests/expected/default-model/unselected.expected.md create mode 100644 packages/client/ui-model-selection/tests/expected/unselected-model.txt create mode 100644 packages/client/ui-settings-account/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt create mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 304bdb97f6..f9fd8da7ea 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -17,7 +17,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' -import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' /** Points the shipped shared Agent default at this scenario's own route. */ @@ -135,10 +135,17 @@ describe('web e2e: the composer model switch is the default for later sessions', // default still names the route, and nothing serves it any more. // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every // stored profile in place. - await scaffold.ctx.settings.replace('llm-pi-ai', { providers: {} }) + await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { + [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', + baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, + } }) await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) - expect(await box.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型') + const unset = page.getByRole('button', { name: '请选择模型', exact: true }) + await unset.waitFor() + expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) + const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) // The block is an affordance; the refusal is the Host's. A client that // never disabled anything still cannot start a turn on a dead route. @@ -151,7 +158,7 @@ describe('web e2e: the composer model switch is the default for later sessions', // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. - const seat = page.getByRole('button', { name: /^选择模型/ }) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) expect(await seat.isEnabled()).toBe(true) await seat.click() await page.getByRole('menuitem', { name: /模型/ }).click() diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md new file mode 100644 index 0000000000..c4fb007e9c --- /dev/null +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -0,0 +1,8 @@ +- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] +- button "添加文件或调用指令" [disabled]: + - img +- button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 +- button "请选择模型": + - text: 请选择模型 + - img +- button "发送消息" [disabled] diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 72ffc23a6d..e174fcd03e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 3d5ad12ef85b6b3c4fedde87cea8963f4a20d93b -event-producer-consumer.zh.md: c212611c25f60283f91ef58530121d84092be936 +event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 +event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3d5ad12ef8..c5535c610e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c212611c25..b2e558f5e7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,7 +39,7 @@ | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 23bd8abfb0..d7679f8cfd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: ad5bb66073bc7c3381739935eff593d49343b851 -core.zh.md: d0ec1aed5f3d504e4cd5e781c3a6927f311cbc7b +core.md: 5b51a81ff910072e67ffb796cbc9276a00827dcc +core.zh.md: 8c2fd014203500990b29ea374c45fad2f4ef2b42 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ad5bb66073..5b51a81ff9 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -434,11 +434,18 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise + +/** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ +async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index d0ec1aed5f..8c2fd01420 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -444,11 +444,18 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise + +/** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ +async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..84327833d3 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: 202f6adf6e89e1293feb5181c1397b4725720ced +session.zh.md: feb2cc0845b426bad0046d0a8a7a4737b210a8f4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..202f6adf6e 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -818,6 +818,13 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ +@Remote async initializeDefaultModel(provider: string): Promise + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..feb2cc0845 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -822,6 +822,13 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ +@Remote async initializeDefaultModel(provider: string): Promise + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 115cbff692..b9cecc5b13 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -24,6 +24,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'api-session/removed', mode: 'emit' }, { event: 'api-session/status', mode: 'emit' }, { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/record-updated', mode: 'emit' }, { event: 'credentials/reference-updated', mode: 'emit' }, { event: 'goal/activation-changed', mode: 'emit' }, { event: 'cordis/request-run', mode: 'emit' }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d04576c269..b178f956cf 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: a0e18910d14f879171a1415476a7e05a0d261a8a -README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034 +README.md: 5f09de61395c3d729144203b77eeb299c217128e +README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a0e18910d1..5f09de6139 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,6 +68,8 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58466e387f..dfb5b97b58 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,6 +68,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 + ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index 0c97107f03..df751d86ba 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -59,9 +59,21 @@ export async function buildModelCatalog( })) return { default: { ...defaultSelection }, - routableProviders: providers.map(provider => provider.id), + routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) .filter(group => group.models.length > 0), failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } + +/** + * Check a GUI selection against the current available provider catalog. + * @param ctx - Host LLM registry. + * @param selection - stored or explicitly requested selection. + * @returns whether the exact model is currently advertised as available. + */ +export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { + if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false + const models = await ctx.llm.listModels(selection.provider) + return models.some(model => model.id === selection.model) +} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 03c697734b..e73659d4cb 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -1,5 +1,6 @@ /** Session commands whose activation policy is explicit at each Remote method. */ +import { modelAvailable } from './catalog.ts' import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' @@ -151,6 +152,7 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) return this.agents.serializeImageAdmission(agent, async () => { try { + await this.requireModel(request) const resolved = await this.ctx.llm.resolveCallConfig({ provider: request.provider, model: request.model, @@ -329,13 +331,7 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current - if (!routeServed(this.ctx, selection.provider)) { - throw new RemoteError( - 'session/model-unavailable', - `no adapter serves provider "${selection.provider}"; select a model for this session`, - { provider: selection.provider, model: selection.model }, - ) - } + await this.requireModel(selection) const source: MessageSource = { kind: 'user', rpcId: request.requestId, @@ -361,6 +357,7 @@ export class SessionCommandController { ) const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) + await this.requireModel(this.agents.selectionFor(agent).current) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -384,6 +381,13 @@ export class SessionCommandController { return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } + private async requireModel(selection: Pick): Promise { + if (!await modelAvailable(this.ctx, selection)) { + throw new RemoteError('session/model-unavailable', 'Select an available model before sending a message.', + { provider: selection.provider, model: selection.model }) + } + } + /** * Read one durable image after proving the Session log references it. * @param request - Session and attachment identities used for authorization. @@ -690,7 +694,3 @@ function referencedImage( } return undefined } - -function routeServed(ctx: Context, provider: string): boolean { - return ctx.llm.listProviders().some(entry => entry.id === provider) -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6f5f0dfa0..6f51c15da4 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -270,6 +270,21 @@ export class SessionController extends TypertRemoteService { return this.commands.selectModel(request) } + /** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ + @Remote + async initializeDefaultModel(provider: string): Promise { + const catalog = await buildModelCatalog(this.ctx) + const model = catalog.groups.find(group => group.id === provider)?.models[0] + if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, + ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, + }) + } + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..c5b92ec0cc 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -143,7 +143,7 @@ export interface ModelCatalogFailure { /** Host-generation model catalog and the default used by unconfigured Sessions. */ export interface ModelCatalog { readonly default: ModelSelection - /** Provider routes currently able to serve a request, including empty catalogs. */ + /** Provider routes with at least one currently available catalog model. */ readonly routableProviders: readonly string[] readonly groups: readonly ModelProviderGroup[] readonly failures: readonly ModelCatalogFailure[] diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4ff61a9b9a..14527cccec 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -83,6 +83,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }, } as never) ctx.provide('llm', { + listModels: async () => [{ id: 'fixture-model', name: 'Model' }], listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), } as never) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index c8be6f2252..8995fa87a1 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -816,6 +816,7 @@ describe('sessions.prompt synchronous rejection', () => { AttachmentStore.prototype, ) as never) ctx.provide('llm', { + listModels: async () => [{ id: 'm', name: 'Model' }], listProviders: () => [{ id: 'p', name: 'Provider' }], resolveModelInfo: () => Promise.resolve({ provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'], diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 51133aafde..26d71d839e 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -1,7 +1,7 @@ /** * Session Controller model-directory and selection behavior: dynamic provider grouping, * provider-local catalog failures, logged-selection restoration without stale - * catalog injection, advisory pass-through models, and the prompt-assembly + * catalog injection, unavailable-model refusals, and the prompt-assembly * boundary for a running selection change. */ @@ -23,7 +23,8 @@ import { ApiSessionAgentController } from '../src/agent.ts' import { buildModelCatalog } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { createSessionTestRemote } from './test-remote.ts' +import AgentDefaultModel from '@deepseek-ai/dsh-agent-default-model' +import { createSessionTestController, createSessionTestRemote } from './test-remote.ts' function request

(payload: P): P { return payload @@ -146,7 +147,7 @@ function registerTextOnly(ctx: Context): void { override resolveModel(provider: string, model: string): Promise { return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) } - }('Text Only', [])) + }('Text Only', [{ provider: 'text-only', id: 'plain', name: 'Plain' }])) } /** Resolve the Client-visible next selection from durable state and the Host default. */ @@ -266,7 +267,6 @@ describe('Web session model selection', () => { ], })) expect(result.ok).toBe(true) - expect(followup).not.toHaveBeenCalled() expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'text', text: 'look at this' }, { @@ -347,6 +347,23 @@ describe('Web session model selection', () => { expect(readImage).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) + it('initializes only the first explicitly configured provider and retains later user choices', async () => { + const { ctx } = await harness() + await ctx.plugin(AgentDefaultModel, { provider: 'missing', model: 'missing' }) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', + }) + await controller.initializeDefaultModel('deepseek-official') + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high', + }) + await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) + await controller.initializeDefaultModel('deepseek-official') + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) + await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await ctx.fiber.dispose() + }) + it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', @@ -428,7 +445,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { + it('rejects unlisted models and switches available models only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } @@ -440,12 +457,12 @@ describe('Web session model selection', () => { const selected = expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }))) expect(selected.selected).toEqual({ provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }) await expect(agentEvents(ctx, agent).waterfall( @@ -453,26 +470,26 @@ describe('Web session model selection', () => { )).resolves.toEqual(seed) expect((await ctx.systemPrompt.assemble()).variables) - .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) + .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) await expect(agentEvents(ctx, agent).waterfall( 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }) const unsupported = await remote.selectModel(request({ sessionId, provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'medium', })) expect(unsupported).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', - message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"', + message: 'provider "deepseek-official" model "deepseek-reasoner" does not support reasoning effort "medium"', }, }) @@ -485,7 +502,7 @@ describe('Web session model selection', () => { ok: false, error: { code: 'session/model-unavailable', - message: 'no adapter registered for provider "missing"', + message: 'Select an available model before sending a message.', details: { provider: 'missing', model: 'model' }, }, }) @@ -496,13 +513,13 @@ describe('Web session model selection', () => { }))).toMatchObject({ ok: false, error: { - code: 'gateway/internal', - message: 'fixture rejected the selection', - details: {}, + code: 'session/model-unavailable', + message: 'Select an available model before sending a message.', + details: { provider: 'remote-rejected', model: 'model' }, }, }) expect(currentSelection(ctx, sessionId)) - .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) @@ -614,15 +631,23 @@ describe('Web session model selection', () => { const unavailableCatalog = await buildModelCatalog(ctx) expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) - // An advisory-unlisted model on a live route is NOT this: the route - // serves it, so the prompt goes through and nothing blocks. - expectValue(await remote.selectModel(request({ + expect(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', - }))) - const catalog = await buildModelCatalog(ctx) - expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true) - expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) - .not.toContain('unlisted-but-served') + }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + await ctx.fiber.dispose() + }) + + it('refuses a removed model while its provider still serves other models without changing the log', async () => { + const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const events = [...agent.session.snapshotEvents()] + expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'hi' }] }))) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + expect(agent.session.snapshotEvents()).toEqual(events) + expect(currentSelection(ctx, sessionId)).toMatchObject({ provider: 'deepseek-official', model: 'removed-model' }) await ctx.fiber.dispose() }) @@ -654,13 +679,13 @@ describe('Web session model selection', () => { provider, id: model, name: model, inputModalities: ['text', 'image'], }) } - }('Image Capable', [])) + }('Image Capable', [{ provider: 'image-capable', id: 'vision', name: 'Vision' }])) ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter { override resolveModel(): Promise { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. return Promise.reject('string selection failure') } - }('String Error', [])) + }('String Error', [{ provider: 'string-error', id: 'plain', name: 'Plain' }])) let saveMode: 'success' | 'error' | 'remote' = 'success' const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, @@ -725,12 +750,12 @@ describe('Web session model selection', () => { sessionId, provider: 'image-capable', model: 'vision', }))) expect(await remote.selectModel(request({ - sessionId, provider: 'metadata-broken', model: 'broken', + sessionId, provider: 'metadata-broken', model: 'listed', }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' }, }) expect(await remote.selectModel(request({ - sessionId, provider: 'string-error', model: 'broken', + sessionId, provider: 'string-error', model: 'plain', }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', message: 'string selection failure' }, diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..68e5f9f779 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -248,6 +248,10 @@ function installControllers( } if (ctx.get('llm') === undefined) { ctx.provide('llm', { + listModels: async () => { + const selection = defaults.defaultModelSelection() + return [{ id: selection.model, name: selection.model }] + }, listProviders: () => { const selection = defaults.defaultModelSelection() return [{ id: selection.provider, name: selection.provider }] diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..f6eaa6d8b4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 7be555c8866a7d69631888be5f6383f8ec0a772a +README.zh.md: 74a4d083af5f2bb0ca8ea9a12cd3679c4860aaf1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..7be555c886 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -60,7 +60,7 @@ The shell reads the persisted View preference before rendering when a Session fi Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)). -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Model availability blocks retain the normal input placeholder while leaving the model picker enabled. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..74a4d083af 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -60,7 +60,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 模型不可用导致的阻塞保留常规输入占位文案,模型选择器仍可操作。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..42bc9724df 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -133,7 +133,7 @@ export function ConversationContent(props: ConversationContentProps) { // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, // not a different tree, so the textarea DOM survives the transition. const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block is the same inert posture with the blocker's own reason: + // A raised block keeps the same textarea mounted: // one disabled textarea, never a second tree. The no-workspace state wins // when both hold — picking a workspace is the earlier prerequisite. const blocked = !inert && composerBlock !== undefined @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, placeholder: composerBlock.reason } + ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 4ca0dee8fc..9ba09cab47 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 96bf249b8976ad56b5abb70975635fa452c700f8 -README.zh.md: 71eb0de85bd87e5b2b92bf33d19f925e649f92a7 +README.md: 7afc8f158509e18e90236d6302ab51125fa7a891 +README.zh.md: c14ad7ab05af24c35727d42e6d843cbdaf20f332 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 96bf249b89..7afc8f1585 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If no adapter can serve the session's route, the composer remains disabled until routing becomes available. +The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again. ## Table of Contents @@ -27,7 +27,7 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. ### Model and effort @@ -35,7 +35,7 @@ Models stay grouped by provider. The composer menu shows model and effort names ### Unroutable sessions -When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. @@ -51,7 +51,7 @@ When another writer owns the Session, model-selection failures tell the user to

Implementation internals — click to expand -Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. +Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 71eb0de85b..c14ad7ab05 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。如果没有适配器可以服务会话路由,composer 会保持停用,直至路由恢复可用。 +Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。 ## 目录 @@ -27,7 +27,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 ### 模型与推理强度 @@ -35,7 +35,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 不可路由的会话 -当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 @@ -51,7 +51,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
实现细节——点击展开 -两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的建议目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated` 与 `settings/document-updated` owner 事件上重拉。 +两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index b27811f501..8e03bcf206 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,15 +15,9 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Effective selection: durable next-request projection, then Host default. */ + /** Available durable selection, or the available default for an unselected Session. */ current: ModelSelection | null - /** - * Whether an adapter serves the current selection's provider, as the host reports - * it — null before the first load, which is NOT the same as blocked. Read - * this rather than "current matches no group": catalog membership is - * advisory, so a route serving a model it stopped advertising is missing - * from the groups yet perfectly usable. - */ + /** Whether the current selection is present in the available catalog; null while unresolved. */ routable: boolean | null /** Successfully loaded provider groups (last good load). */ groups: readonly ModelProviderGroup[] @@ -45,7 +39,6 @@ export class ModelDirectory { /** Latest selection operation wins; an older response never overwrites a newer one. */ private generation = 0 private disposed = false - private resolved = false private readonly unsubscribeCatalog: () => void private readonly unsubscribeSelection: () => void @@ -69,7 +62,7 @@ export class ModelDirectory { } /** - * Ensure the Host generation's shared advisory catalog is loaded. + * Ensure the Host generation's shared available catalog is loaded. * @returns the fresh directory value. */ async load(): Promise { @@ -144,15 +137,6 @@ export class ModelDirectory { const catalog = this.catalog.store.getSnapshot() const projected = modelSelectionProjection(this.projected.getSnapshot()) if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { - if (this.resolved) { - if (catalog.status === 'error') { - this.store.update((state) => { - state.status = 'error' - state.error = catalog.error - }) - } - return - } this.store.set({ current: null, routable: null, @@ -163,11 +147,12 @@ export class ModelDirectory { }) return } - const current = projected.next ?? catalog.value.default - this.resolved = true + const intended = projected.next ?? catalog.value.default + const current = catalog.value.groups.some(group => group.id === intended.provider + && group.models.some(model => model.id === intended.model)) ? intended : null this.store.set({ current, - routable: catalog.value.routableProviders.includes(current.provider), + routable: current !== null, groups: catalog.value.groups, failures: catalog.value.failures, status: this.store.getSnapshot().status === 'selecting' diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2e573796ea..2ab727b353 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -15,9 +15,9 @@ export const zh = { 'option.loadError': '目录加载失败:{message}', 'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。', 'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。', - 'trigger.fallback': '选择模型', + 'trigger.fallback': '请选择模型', 'trigger.loading': '正在加载模型…', - 'trigger.selectAria': '选择模型', + 'trigger.selectAria': '请选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index d92c2c08b3..cee8e630d1 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -57,6 +57,7 @@ export class ModelDirectoryResolver extends Service { }) ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() }) ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() }) + ctx.remote.$on('credentials/record-updated', () => { this.catalog.refresh() }) ctx.remote.$on('credentials/reference-updated', () => { this.catalog.refresh() }) } @@ -83,16 +84,11 @@ export class ModelDirectoryResolver extends Service { binding.session.projections.faceOf('modelSelection'), ) live.directories.set(binding, directory) - // The composer cannot read this plugin (the dependency runs one way), so - // the block is pushed: the Host says whether an adapter serves the - // session's route, and only a definite `false` makes the input inert. - // `null` — before the first load, or after one failed — must not, or a - // slow or unreachable Host would lock a working composer. const conversation = this.ctx.get('conversation') if (conversation !== undefined) { const publish = (): void => { if (sessions.binding(sessionId) !== binding) return - conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false + conversation.blocks.set(sessionId, directory.store.getSnapshot().routable !== true ? { reason: this.blockReason() } : undefined) } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ecae6a1281..67484a5d95 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -83,7 +83,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: GROUPS, + groups: routable ? GROUPS : [], failures: [], }, }) @@ -299,7 +299,7 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(1) }) - it('keeps the durable projected selection while the eager catalog reconnects', async () => { + it('hides the effective selection until the reconnected catalog validates it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -308,17 +308,17 @@ describe('ui-model-selection dual entry', () => { b.ctx.emit('connection/reset') expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, - status: 'ready', + current: null, + status: 'loading', }) face.load() expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, - status: 'ready', + current: null, + status: 'loading', }) }) - it('keeps the last complete view while a refreshed catalog catches up with projection', async () => { + it('blocks until a refreshed catalog validates the projected selection', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -331,8 +331,8 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - status: 'ready', + current: null, + status: 'loading', }) await vi.waitFor(() => { @@ -415,13 +415,11 @@ describe('ui-model-selection dual entry', () => { } }) - it('blocks the composer only once the Host reports the route unservable', async () => { + it('blocks the composer when current catalog models disappear', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) - // Before the first load nothing is known. `null` is not `false`: a slow - // or unreachable Host must never lock a working composer. expect(b.blockOf('s1')).toBeUndefined() face.load() await Promise.resolve() @@ -445,20 +443,16 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(3) }) - it('never blocks on catalog membership alone', async () => { + it('hides an unavailable durable selection without replacing or rewriting it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) - // A model the route serves but no longer advertises: the seat prompts for - // a selection, the composer stays usable. Blocking here would break a - // supported configuration (a narrowed `models` list over a live route). - b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' }) - face.load() - await Promise.resolve() - await Promise.resolve() - const snapshot = face.directory.getSnapshot() - expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted') - expect(b.blockOf('s1')).toBeUndefined() + const intended = { provider: 'deepseek-official', model: 'unlisted' } + b.setProjected(sid('s1'), { lastUsed: intended, next: intended }) + await vi.waitFor(() => { expect(face.directory.getSnapshot().status).toBe('ready') }) + expect(face.directory.getSnapshot().current).toBeNull() + expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) + expect(b.calls.select).toBe(0) }) it('clears its block when the session scope goes', async () => { diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt new file mode 100644 index 0000000000..d6d4beb3b1 --- /dev/null +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -0,0 +1 @@ +请选择模型 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 4a5ee43b91..3ac3613aac 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -446,3 +446,12 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(rows[0]) }) }) + +it('shows the unselected model control without a stale model or effort', async () => { + const directory = createSnapshotStore(state({ current: null, routable: false })) + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + expect(trigger.hasAttribute('disabled')).toBe(false) + await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') + expect(trigger.textContent).not.toContain('High') +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 3e8fa7838f..a9fcd71d5a 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: e9df6c0f3dd20d47c688d3ca23b6cff88ab72e99 -README.zh.md: 775d518b1c0e75f7fbd191bc21e57ac24c7f8179 +README.md: d54687d083897842518a0c95dfc84d39e985f3a1 +README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index e9df6c0f3d..d54687d083 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -50,6 +50,9 @@ Account login uses a dismissible dialog before the model onboarding credential e The balance card shows recharge funds and positive bonus credit in separate rows. Empty or nonpositive bonus wallets hide the bonus row and its divider; currencies retain their own amounts. + +A successful first login initializes the default model from `deepseek-account` when no user choice has been saved. Later logins retain the saved choice, including an unavailable one. + ## Model Experience diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 775d518b1c..c4dbcf234f 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -50,6 +50,9 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。 + +首次成功登录且用户尚未保存模型选择时,从 `deepseek-account` 初始化默认模型。后续登录保留已保存的选择,包括不可用的选择。 + ## 模型体验 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index a5b1c715ab..da26bbd8d8 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'settings.account': AccountKey } } /** Services required by account settings. */ -export const inject = ['slots', 'locale', 'remote', 'remote.account'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session'] /** @param ctx - browser plugin context. */ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries') @@ -72,7 +72,17 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) + let initializationFailed = false + if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded') { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + initializationFailed = !initialized.ok + } catch (_error) { + initializationFailed = true + } + } + publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) browser.update(frame.value) frame.accept() void refresh() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts new file mode 100644 index 0000000000..7f01fc5f6e --- /dev/null +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** Account state updates continue after default-model initialization refuses. */ +import { expect, it, vi } from 'vitest' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply, type AccountSectionInjected } from '../src/client/index.ts' + +it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const abort = new AbortController() + const done = Promise.withResolvers() + const release = Promise.withResolvers() + const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'disconnected') throw new Error('connection lost') + return { ok: outcome === 'accepted', value: undefined } + }) + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { + status: 'credential-stored', links, attempt: { id: 'login' as SignInAttemptId, phase: 'succeeded' }, + } + const frames: AccountView[] = [signedIn, signedIn, { status: 'signed-out', links, attempt: null }] + const disposers: (() => void)[] = [] + let operations: AccountSectionInjected | undefined + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) disposers.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + if (dispose) disposers.push(dispose) + }, + register: (entry: { inject?: () => AccountSectionInjected }) => { + if (entry.inject) operations = entry.inject() + return () => {} + }, + }, + remote: { + session: { initializeDefaultModel }, + account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => ({ + signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, + async *[Symbol.asyncIterator]() { + for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + await release.promise + }, + }), + }, + } + try { + apply(ctx as never) + await done.promise + expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) + } finally { + for (const dispose of disposers.reverse()) dispose() + } +}) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 59be36dcbd..c84f99abf1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 19cf77c41f3c391622945a8eb28ab2bfa104f270 -README.zh.md: 47d67039ab7fcdf7ca8e0781498aa795dac7e002 +README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 +README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 19cf77c41f..ccce6981a6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,6 +55,8 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. Account availability follows the stored account credential. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. + ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 47d67039ab..0d2f64a1fe 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,6 +55,8 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用性取决于已保存的账号凭据。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 + ----- diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 4f382e92a1..c84e72f743 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return undefined + return operations.initializeModel(route.trim()) } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index a9ce163a50..c61b380a25 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -139,7 +139,7 @@ export async function removeProviderProfile( * @returns whether to render the setup card. */ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { - if (anyUsable) return false + if (anyUsable || row.entry.provider === 'deepseek-account') return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -203,7 +203,9 @@ export function ModelsSection(props: ModelsSectionProps): ReactNode { function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { const { controller, operations, schema, t } = injected - const state = injected.useSnapshot(snapshot => snapshot) + const snapshot = injected.useSnapshot(value => value) + const state = { ...snapshot, rows: snapshot.rows.map(row => row.entry.provider === 'deepseek-account' + ? { ...row, entry: { ...row.entry, displayName: t('deepSeekAccount') } } : row) } const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) const [deleteTarget, setDeleteTarget] = useState(undefined) diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index bc89f43fc8..93e1fa7d1e 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -173,6 +173,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const fallback = schema.getPath(namespace.value, settingsPath) const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) + const accountProvider = props.provider === 'deepseek-account' const keyRef = refFor(schema, namespace, settingsPath, props.provider) // The same schema read the create card makes, so the choices offered here // and there cannot drift apart: both come from the adapter's own `Config`. @@ -184,6 +185,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ) useEffect(() => { + if (accountProvider) return let stale = false setKeyState(undefined) // The key state is a placeholder hint, not a precondition for editing: a @@ -193,7 +195,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setKeyState(described) }) return () => { stale = true } - }, [operations, keyRef]) + }, [operations, keyRef, accountProvider]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -286,6 +288,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored + const initialized = await operations.initializeModel(props.provider) + if (initialized !== undefined) return initialized } setKeyDraft('') return undefined @@ -358,6 +362,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } + if (accountProvider) return return ( <>
diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index ddce8c9e49..9ce0e71534 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -64,7 +64,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * constrained; registration depends on each slot through `slots.inject()`. */ export const inject = [ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'remote.session', 'settingsScope', 'settingsSchema', ] @@ -125,6 +125,7 @@ export function apply(ctx: ClientContext): void { const refreshModels = (): void => { refreshIfLoaded(controller) } const disposers = [ ctx.remote.$on('settings/document-updated', () => { refreshModels() }), + ctx.remote.$on('credentials/record-updated', refreshModels), ctx.remote.$on('credentials/reference-updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), ctx.on('connection/reset', refreshModels), diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..1bafbdc514 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -3,6 +3,7 @@ /** English strings (the key-set source of truth for this pair). */ export const en = { nav: 'Models', + deepSeekAccount: 'DeepSeek Account', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', edit: 'Edit', @@ -115,6 +116,7 @@ export type ModelsKey = keyof typeof en /** Chinese strings (same keys as {@link en}). */ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', + deepSeekAccount: 'DeepSeek 账号', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', edit: '编辑', diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 8ca6beb798..75d87ac821 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,6 +32,8 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { + /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -81,6 +83,10 @@ export interface ModelsOperations { */ export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { + initializeModel: async (provider) => { + const response = await ctx.remote.session.initializeDefaultModel(provider) + return response.ok ? undefined : response.error.message + }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) return response.ok ? response.value[ref] : undefined diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 2af1d54d5c..f3e948b79f 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -69,6 +69,8 @@ export function joinProviderDirectory( /** One provider row the page renders. */ export interface ProviderRow { + /** Account route has usable credentials for the configured inference origin. */ + accountAvailable?: boolean /** The directory entry (route id, display name, settings address, live state). */ entry: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves). */ @@ -208,11 +210,18 @@ export class ModelsSettingsStore { entry, configured, removable, - apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath, this.schema), + apiKeyEnv: entry.provider === 'deepseek-account' ? undefined : apiKeyEnvOf(namespace, entry.settingsPath, this.schema), credential: undefined, } }) - const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] + if (rows.some(row => row.entry.provider === 'deepseek-account')) { + const catalog = await this.ctx.remote.session.modelCatalog() + for (const row of rows) { + if (row.entry.provider === 'deepseek-account') row.accountAvailable = catalog.ok + && catalog.value.groups.some(group => group.id === 'deepseek-account' && group.models.length > 0) + } + } + const refs = [...new Set(rows.filter(row => row.entry.provider !== 'deepseek-account').map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { @@ -230,6 +239,7 @@ export class ModelsSettingsStore { s.credentialError = credentialError s.writable = writable s.rows = rows.map((row) => { + if (row.entry.provider === 'deepseek-account') return row const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] return { @@ -264,6 +274,7 @@ export class ModelsSettingsStore { */ export function providerUsable(row: ProviderRow): boolean { if (!row.entry.active) return false + if (row.entry.provider === 'deepseek-account') return row.accountAvailable === true if (row.apiKeyEnv === undefined) return true return row.credential?.configured === true } diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 22e1d04f06..408de5248f 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -45,6 +45,7 @@ async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDe ...services, }, settings: mock.remote.settings, + session: { initializeDefaultModel: vi.fn(async () => ({ ok: true, value: undefined })) }, }) // The fixed Host facts the settings provider reads its persistence from. remote.$host = { home: undefined, isLoopback } @@ -107,7 +108,7 @@ describe('ui-settings-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'remote.session', 'settingsScope', 'settingsSchema', ]) }) diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..88e43e599f 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -12,7 +12,7 @@ import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' -import { pathOps } from '../src/client/ProviderEditor.tsx' +import { ProviderEditor, pathOps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' @@ -22,7 +22,7 @@ import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import { createModelsOperations } from '../src/client/operations.ts' import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -218,7 +218,9 @@ const contexts = new WeakMap() function ctxWith(face: object): PageContext { const existing = contexts.get(face) if (existing !== undefined) return existing - const ctx = { remote: face } as unknown as PageContext + const ctx = { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } } as unknown as PageContext contexts.set(face, ctx) return ctx } @@ -1620,3 +1622,18 @@ describe('apiKeyFailure', () => { expect(apiKeyFailure('"a')).toBeUndefined() }) }) + +it.each([en, zh])('edits the account model catalog without credential or endpoint fields', async (copy) => { + const scripted = scriptedFace({}) + const ops = operationsWith(scripted.face) + const describe = vi.spyOn(ops, 'describeCredential') + render( copy[key]} readOnly={false} onClose={() => {}} />) + expect(screen.queryByLabelText(copy.keyInput)).toBeNull() + expect(screen.queryByLabelText(copy.baseUrl)).toBeNull() + expect(describe).not.toHaveBeenCalled() + expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() + await expect(`${document.body.textContent}\n`) + .toMatchFileSnapshot(`./expected/deepseek-account-${copy === en ? 'en' : 'zh'}.txt`) +}) diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt new file mode 100644 index 0000000000..c9841889a6 --- /dev/null +++ b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt @@ -0,0 +1 @@ +DeepSeek Accountdeepseek-accountModelsUsing the adapter defaultsAdd modelCancelApply diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt new file mode 100644 index 0000000000..e11c2e1969 --- /dev/null +++ b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt @@ -0,0 +1 @@ +DeepSeek 账号deepseek-account模型目录正在使用适配器默认模型添加模型取消保存 diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index 60e7d79165..f5c50b3056 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -12,6 +12,7 @@ function operations(discoverModels: ModelsOperations['discoverModels']): ModelsO return { discoverModels, describeCredential: vi.fn(), + initializeModel: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn(), diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 89bc6b2ca7..d19873f3b2 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -136,7 +136,7 @@ function harness(options: { }, } // The page plugin's context, scripted down to the namespaces it reaches. - const ctx = { remote: face } as never + const ctx = { remote: { ...face, session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) } } } as never const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 57c61d702b..ae71cf0557 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -153,7 +153,9 @@ const contexts = new WeakMap() function ctxWith(face: object): PageContext { const existing = contexts.get(face) if (existing !== undefined) return existing - const ctx = { remote: face } as unknown as PageContext + const ctx = { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } } as unknown as PageContext contexts.set(face, ctx) return ctx } diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 0ce5a995a1..d181edc12e 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,7 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts' +import { joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ @@ -64,6 +64,7 @@ const NAMESPACES = [ ] function api(overrides: { + accountAvailable?: boolean providers?: () => Promise> describeSettings?: () => Promise> describeCredentials?: (refs: readonly string[]) => Promise>> @@ -88,6 +89,8 @@ function api(overrides: { : remoteFail(response.result.error.message) } const face = { + session: { modelCatalog: async () => remoteOk({ groups: overrides.accountAvailable + ? [{ id: 'deepseek-account', models: [{ id: 'deepseek-flash' }] }] : [] }) }, llm: { listProviders: () => mapProviderBatch(rows => rows .filter(row => row.active) @@ -329,3 +332,16 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) }) + + +it.each([false, true])('uses account availability without asking for an API key: %s', async (accountAvailable) => { + const { ctx, mirror, seenRefs } = api({ accountAvailable, providers: async () => ok({ providers: [{ + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + }] }) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) + await store.load() + const row = store.store.getSnapshot().rows[0]! + expect(row).toMatchObject({ accountAvailable, apiKeyEnv: undefined, credential: undefined }) + expect(providerUsable(row)).toBe(accountAvailable) + expect(seenRefs).toEqual([]) +}) diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index d32bdafa59..421bdb1e70 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md -README.md: df8b9c37ea58f4d35d2f9bda1f1e7001c8b2de59 -README.zh.md: d9d78452de9e0fa2a20bcc0bd9d16c41c69e1729 +README.md: 97130a007d91f936450e3593a4ea823ca90d4ae2 +README.zh.md: 2cb9d0b837c55c976c712f286f89a65eb0afab3a diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index df8b9c37ea..97130a007d 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -54,7 +54,7 @@ const selection = ctx.agentDefaultModel.currentSelection() await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'high' }) ``` -Without a settings provider, `saveSelection()` is a no-op and the composition entry remains current. The service does not validate catalog membership: a provider route may serve an unadvertised model, and the consumer that opens a model request owns availability diagnostics. +Without a settings provider, `saveSelection()` retains the choice in memory for later agents in this process. `initializeSelection()` saves the first setup choice only when no user choice exists; an unavailable saved choice remains intact. The service does not validate catalog membership: a provider route may serve an unadvertised model, and the consumer that opens a model request owns availability diagnostics. ----- @@ -79,7 +79,7 @@ The service is a composition entry with a settings-backed source. The plugin con ### Behavior notes -Both public methods are thin reads and writes over that source: `currentSelection()` returns a fresh detached object so a caller can hold it without aliasing service state, and `saveSelection()` writes the whole selection through `ctx.settings` when present. +Reads return detached values and writes are serialized: `currentSelection()` returns a fresh detached object so a caller can hold it without aliasing service state, and `saveSelection()` writes the whole selection through `ctx.settings` when present. @@ -114,7 +114,7 @@ Changing the default affects only agents that subsequently resolve from it. An e These limits define the service's scope. They are current package constraints, not a task backlog. - **One process-wide default** — the service owns a single default; per-session model selection remains the entry point's responsibility. -- **No retention without a settings provider** — `saveSelection()` cannot keep a selection for a later agent when no settings provider is mounted. +- **No persistence without a settings provider** — in-memory selections last only for this process. ### Dev Note diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index d9d78452de..2cb9d0b837 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -54,7 +54,7 @@ const selection = ctx.agentDefaultModel.currentSelection() await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'high' }) ``` -未挂载设置提供方时,`saveSelection()` 不执行任何操作,组合配置项仍为当前值。该服务不校验目录成员关系:提供方路由可以服务未在目录中公布的模型;发起模型请求的消费方负责可用性诊断。 +未挂载设置提供方时,`saveSelection()` 在内存中保留选择,供本进程后续 agent 使用。`initializeSelection()` 仅在用户尚未选择时保存首次配置的模型;已保存的选择即使不可用也保持不变。该服务不校验目录成员关系:提供方路由可以服务未在目录中公布的模型;发起模型请求的消费方负责可用性诊断。 ----- @@ -79,7 +79,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h ### 行为说明 -两个公开方法都只是对该数据源进行简单读写:`currentSelection()` 返回一个全新、独立的对象,因此调用方可以持有它,而不会与服务状态共享引用;`saveSelection()` 在存在 `ctx.settings` 时写入完整选择。 +读取返回独立值,写入按序执行:`currentSelection()` 返回一个全新、独立的对象,因此调用方可以持有它,而不会与服务状态共享引用;`saveSelection()` 在存在 `ctx.settings` 时写入完整选择。 @@ -114,7 +114,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h 这些限制界定该服务的范围。它们是当前包约束,不是任务积压。 - **单一的进程级默认值**——该服务只拥有一个默认值;按会话的模型选择仍由入口负责。 -- **没有设置提供方时无法保留**——未挂载设置提供方时,`saveSelection()` 无法为后续 agent 保留选择。 +- **没有设置提供方时无法持久化**——内存中的选择仅在本进程内保留。 ### 开发备注 diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 82e4e0da6d..a2c804836c 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -68,6 +68,8 @@ export class AgentDefaultModelConfig extends Service { }) private source: () => AgentDefaultModelSettings + private saved: ModelSelection | undefined + private writes: Promise = Promise.resolve() constructor(ctx: Context, config: Config) { super(ctx, 'agentDefaultModel') @@ -88,22 +90,45 @@ export class AgentDefaultModelConfig extends Service { * @returns a detached provider, model, and optional reasoning selection. */ currentSelection(): ModelSelection { - return selection(this.source()) + return this.ctx.get('settings') === undefined && this.saved !== undefined + ? { ...this.saved } : selection(this.source()) } /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise { - await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { - provider: next.provider, - model: next.model, - ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, - }) + return this.writeSelection(next, false) } + + /** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ + async initializeSelection(next: ModelSelection): Promise { + return this.writeSelection(next, true) + } + + private writeSelection(next: ModelSelection, initialize: boolean): Promise { + const operation = this.writes.then(async () => { + const user = this.ctx.get('settings')?.describe().find(entry => entry.ns === AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)?.user + if (initialize && (this.saved !== undefined + || (typeof user === 'object' && user !== null && ('provider' in user || 'model' in user)))) return + await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { + provider: next.provider, + model: next.model, + ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, + }) + this.saved = { ...next } + }) + this.writes = operation.catch(() => { /* A refused write does not block the next user choice. */ }) + return operation + } + } export default AgentDefaultModelConfig diff --git a/packages/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts index b16091d809..be76848e67 100644 --- a/packages/core/agent-default-model/tests/agent-default-model.spec.ts +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -77,22 +77,33 @@ describe('AgentDefaultModelConfig', () => { await bench.ctx.fiber.dispose() }) - it('falls back to the composition entry when the settings provider detaches', async () => { + it('retains the saved selection when the settings provider detaches', async () => { const bench = await boot() await bench.defaultModel.saveSelection({ provider: 'acme-gateway', model: 'acme-large' }) expect(bench.defaultModel.currentSelection().provider).toBe('acme-gateway') await bench.settingsFiber.dispose() - expect(bench.defaultModel.currentSelection()).toEqual({ - provider: 'deepseek-official', model: 'deepseek-v4-flash', - }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'acme-gateway', model: 'acme-large' }) await bench.ctx.fiber.dispose() }) - it('keeps the composition entry when no settings provider is mounted', async () => { + it('retains a user selection when no settings provider is mounted', async () => { const ctx = new Context() await ctx.plugin(AgentDefaultModelConfig, { provider: 'p', model: 'm' }) await ctx.agentDefaultModel.saveSelection({ provider: 'other', model: 'other' }) - expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'p', model: 'm' }) + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'other', model: 'other' }) await ctx.fiber.dispose() }) }) + +it('initializes once and retains an unavailable user selection instead of replacing it', async () => { + const bench = await boot() + try { + await bench.defaultModel.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + expect(bench.defaultModel.currentSelection().provider).toBe('deepseek-account') + await bench.defaultModel.initializeSelection({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) + expect(bench.defaultModel.currentSelection().provider).toBe('deepseek-account') + await bench.defaultModel.saveSelection({ provider: 'deleted-provider', model: 'removed-model' }) + await bench.defaultModel.initializeSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'deleted-provider', model: 'removed-model' }) + } finally { await bench.ctx.fiber.dispose() } +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9e8c680d18..5706f2a77d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -94,10 +94,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise', - description: 'Save the complete default model selection. A deployment without a settings provider keeps its composition entry.', + description: 'Save the complete default model selection. A deployment without a settings provider retains the selection for this process.', parameters: [{ name: 'next', description: 'resolved selection accepted by an entry point.' }], returns: 'fulfillment after the optional settings write settles.', }, + { + signature: 'async initializeSelection(next: ModelSelection): Promise', + description: 'Store the initial credential setup choice only before a user selection exists.', + parameters: [{ name: 'next', description: 'available model belonging to the provider the user initialized.' }], + returns: 'after the initial selection is saved, or immediately if already selected.', + }, ], }, { @@ -1780,6 +1786,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session identity and requested model selection.' }], returns: 'the normalized selection installed for the Session.', }, + { + signature: '@Remote async initializeDefaultModel(provider: string): Promise', + description: 'Initialize the default after the user first configures a provider credential.', + parameters: [{ name: 'provider', description: 'the provider whose credential was configured.' }], + returns: 'after saving the initial available model; existing user choices are retained.', + }, { signature: '@Remote(\'modelCatalog\') modelCatalog(): Promise', description: 'Describe every currently routable model for Host-generation selectors.', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 11c68612d8..fcc298d024 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: abd29fbdc49a100fa3e5a92ad4db05fc487ef456 -README.zh.md: d955dc40190a2b00d9034859c99c004ba06a4fa3 +README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee +README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index abd29fbdc4..7ded16b5e1 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -117,6 +117,8 @@ Successful Files responses must contain valid JSON. JSON decoding failures from Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. +Each route advertises models only while its own credential resolves. Missing API keys and missing or ineligible account grants yield an empty catalog; other credential errors remain visible. This check does not probe the inference endpoint. Both configurable provider entries share the same model catalog. + ----- diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d955dc4019..de612f7b21 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -117,6 +117,8 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 +每条路由仅在其自身凭据可解析时公布模型。缺少 API Key 或账号授权缺失、不适用时返回空目录;其他凭据错误仍会暴露。此检查不会探测推理端点。两个可配置提供方条目共用同一份模型目录。 + ----- diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index c25125987e..568b3190e6 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,6 +1,6 @@ /** Select a DeepSeek wire implementation from one validated configuration generation. */ import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' import type { DeepSeekAdapterOptions } from './common/types.ts' import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts' @@ -41,9 +41,16 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek (Account)' : 'DeepSeek (API Key)' } } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek (API Key)' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } - override listModels(provider: string) { return this.implementation().listModels(provider) } + override async listModels(provider: string) { + try { await this.dependencies.resolveApiKey(this.dependencies.options()) } + catch (error) { + if (error instanceof LlmError && ['MISSING_CREDENTIAL', 'ACCOUNT_SIGN_IN_REQUIRED'].includes(error.code)) return [] + throw error + } + return this.implementation().listModels(provider) + } override resolveModel(provider: string, model: string, signal?: AbortSignal) { return this.implementation().resolveModel(provider, model, signal) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index c740a9e449..f0c262c722 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -134,6 +134,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, + { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 64b050fd02..95ffb361ca 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -214,3 +214,20 @@ it('cancels an account child without stopping its API-key parent', async () => { parent.cancel({ kind: 'user' }) await parent.whenIdle() }) + +it('advertises account and API-key models only while their own credential is available', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = await harness() + let token: string | undefined + ctx.provide('deepseekAccount', { resolveToken: async (url: string) => url === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount) + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + token = 'fixture-account-token' + expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0) + expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-key') + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) + token = undefined + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 2c95e5db00..89d33d64b4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,13 +1697,13 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', displayName: 'DeepSeek (API Key)', settingsNs: 'llm-deepseek', settingsPath: [], - }]) + }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) expect(ctx.llm.listConfigurableProviders()).toEqual([]) @@ -1730,10 +1730,11 @@ describe('plugin registration and config', () => { }) it('owns the deepseek provider and advertises the default models', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -1861,6 +1862,7 @@ describe('plugin registration and config', () => { }) it('uses the default model catalog when apply is called directly', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) @@ -1893,6 +1895,7 @@ describe('plugin registration and config', () => { }) it('advertises configured models without restricting arbitrary request ids', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { @@ -2186,18 +2189,17 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + it('loads keyless, hides unavailable models, and fails the request actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - // First-boot onboarding: the route registers so models stay discoverable; - // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) + // The registered provider remains configurable before its credential exists. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) // The guidance leads with the managed credential store. @@ -2277,7 +2279,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 175e1ab36e..dae9ded55a 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 4b3297ab96c2c691f9bbf849d8e49cb10a126a6a -README.zh.md: b33381468891fa81b199cca40f22a59183f5076a +README.md: ad39c0d53bc41b6c0da4d046a5d0cadd6ce6b3dc +README.zh.md: 082e8e4da8fd9b9948e9284cf9441f1e7d3e9c81 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 4b3297ab96..ad39c0d53b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -120,6 +120,8 @@ Settings writes strictly validate each new or changed provider after merging its Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. +Model discovery resolves each route’s credential configuration. A missing required credential yields an empty catalog; other resolution errors propagate. Routes with native authentication or no required key retain their configured catalog. This check does not probe the inference endpoint. + ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index b333814688..082e8e4da8 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -120,6 +120,8 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或 只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 +模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。 + ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index de9f3e0e42..3370e1ffcc 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -274,9 +274,14 @@ export class PiAiAdapter extends LlmAdapter { } override listModels(provider: string): Promise { - return Promise.resolve().then(() => { + return Promise.resolve().then(async () => { const snapshot = this.current() - this.profileOf(snapshot, provider) + const profile = this.profileOf(snapshot, provider) + try { await this.config.resolveApiKey(provider, profile) } + catch (error) { + if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return [] + throw error + } return snapshot.models.getModels(provider).map(model => ({ provider, id: model.id, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 85534d6773..ef4e3aa4be 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1143,7 +1143,7 @@ describe('resolution snapshots', () => { profiles: () => current, // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. - resolveApiKey: async () => { await held; return 'k' }, + resolveApiKey: async (provider) => { if (provider === 'deepseek') await held; return 'k' }, auth: memoryAuth(), }) From 325e28bfa0e310fd36dd8ecb40ee8f565cc1fb66 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:42:43 +0800 Subject: [PATCH 013/253] fix(ui-model-selection,llm-deepseek): prioritize account models and simplify provider label --- .../client/ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 8 +++++--- .../tests/expected/account-first.txt | 8 ++++++++ .../tests/model-select.client.spec.tsx | 18 ++++++++++++++++++ packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/src/index.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 12 ++++++------ 9 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 packages/client/ui-model-selection/tests/expected/account-first.txt diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 9ba09cab47..2b7317e90e 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 7afc8f158509e18e90236d6302ab51125fa7a891 -README.zh.md: c14ad7ab05af24c35727d42e6d843cbdaf20f332 +README.md: 9b73298b776d2f3d869a5da88db8d17e14f0f3a9 +README.zh.md: 4dbd077a30cbbaaefd9fabcbf1cc4661367a3069 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 7afc8f1585..9b73298b77 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,7 +31,7 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index c14ad7ab05..4dbd077a30 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,7 +31,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index b9c8993563..5e33fbe526 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -75,7 +75,9 @@ export function ModelSelect( const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() - const choices = useMemo(() => state.groups.flatMap(group => + const groups = useMemo(() => state.groups.toSorted((left, right) => + Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, model, @@ -86,7 +88,7 @@ export function ModelSelect( ? {} : { reasoningEffort: model.reasoning.defaultEffort }, } satisfies ModelSelection, - }))), [state.groups]) + }))), [groups]) const selectedIndex = state.current === null ? -1 : choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model) @@ -409,7 +411,7 @@ export function ModelSelect(
))}
- {state.groups.map((group) => { + {groups.map((group) => { const headingId = `${id}-${group.id}` return (
diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt new file mode 100644 index 0000000000..304c901c9f --- /dev/null +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -0,0 +1,8 @@ +deepseek-account-1 +deepseek-account-2 +custom-1 +custom-2 +deepseek-official-1 +deepseek-official-2 +another-1 +another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 3ac3613aac..e71062bbdc 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -455,3 +455,21 @@ it('shows the unselected model control without a stale model or effort', async ( await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') expect(trigger.textContent).not.toContain('High') }) + + +it('places account models first while preserving other provider and model order', async () => { + const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ + id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), + })) + const directory = createSnapshotStore(state({ current: null, groups })) + render() + fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) + expect(names).toEqual([ + 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', + 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + ]) + expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) + await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') +}) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 568b3190e6..95add7b318 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -41,7 +41,7 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek (API Key)' } } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } override async listModels(provider: string) { try { await this.dependencies.resolveApiKey(this.dependencies.options()) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f0c262c722..bd159998e3 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -133,7 +133,7 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 89d33d64b4..fb8c01a25a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,10 +1697,10 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', - displayName: 'DeepSeek (API Key)', + displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) @@ -1734,7 +1734,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2189,7 +2189,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('loads keyless, hides unavailable models, and fails the request actionably', async () => { @@ -2198,7 +2198,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) // The registered provider remains configurable before its credential exists. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2279,7 +2279,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { From 7dc86578d8870fdaea99361670f6d1448c43a86f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:50:14 +0800 Subject: [PATCH 014/253] fix(ui-settings-account,ui-model-selection): align with updated primitives and catalog checks --- .../tests/expected/default-model/unselected.expected.md | 7 ++----- .../ui-model-selection/tests/browser-plugin.client.spec.ts | 7 +++---- .../ui-settings-account/src/client/SignOutDialog.tsx | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md index c4fb007e9c..6228bea41c 100644 --- a/apps/web/tests/expected/default-model/unselected.expected.md +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -1,8 +1,5 @@ - textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] -- button "添加文件或调用指令" [disabled]: - - img +- button "添加文件或调用指令" [disabled] - button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 -- button "请选择模型": - - text: 请选择模型 - - img +- button "请选择模型" - button "发送消息" [disabled] diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 67484a5d95..4fa513e9ba 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -71,8 +71,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { let selected = defaultSelection const calls = { models: 0, select: 0 } const projections = new Map>() - // Whether the Host reports an adapter for the current route; the composer - // block follows this, never catalog membership. + // Whether the Host advertises available models for the current route. let routable = true let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { @@ -382,14 +381,14 @@ describe('ui-model-selection dual entry', () => { await oldDirectory.load() const replacement = b.mint('s1') replacement.projection.set({ lastUsed: null, - next: { provider: routable ? 'deepseek-official' : 'missing', model: 'replacement' } }) + next: { provider: routable ? 'deepseek-official' : 'missing', model: 'deepseek-v4-flash' } }) const directory = b.ctx.modelDirectories.directoryFor(sid('s1')) try { expect(directory.store.getSnapshot().routable).toBe(routable) const expected = b.blockOf('s1') await Promise.resolve().then(() => { first.projection.set({ lastUsed: null, - next: { provider: routable ? 'missing' : 'deepseek-official', model: 'late-old' } }) + next: { provider: routable ? 'missing' : 'deepseek-official', model: 'deepseek-v4-pro' } }) }) expect(oldDirectory.store.getSnapshot().routable).toBe(!routable) expect(b.blockOf('s1')).toBe(expected) diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index c0679a0b2d..f9b59e8ea8 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -1,6 +1,6 @@ /** Account sign-out confirmation with the current task impact. */ import { useState } from 'react' -import { Button, IconCloseOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconCloseOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountKey } from './locales.ts' import css from './SignInDialog.module.css' @@ -26,7 +26,7 @@ export function SignOutDialog({ running, signOut, close, t }: {

{t('signOut')}

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

From 9cf12959f22efe0e43ac097f69a503077a265c12 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:05:58 +0800 Subject: [PATCH 015/253] fix(ui-model-selection): retain effort and open unselected models directly --- apps/web/tests/default-model.e2e.ts | 3 ++- .../ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 ++ .../client/ui-model-selection/README.zh.md | 2 ++ .../src/client/ModelSelect.module.css | 6 ++--- .../src/client/ModelSelect.tsx | 9 ++++---- .../ui-model-selection/src/client/catalog.ts | 19 ++++++++++++++- .../src/client/directory.ts | 15 +++++++++--- .../tests/browser-plugin.client.spec.ts | 23 +++++++++++++++++++ .../tests/expected/unselected-model.txt | 2 +- .../tests/model-select.client.spec.tsx | 13 +++++++---- 11 files changed, 79 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index f9fd8da7ea..40b3911071 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -104,6 +104,7 @@ describe('web e2e: the composer model switch is the default for later sessions', const trigger = page.getByRole('button', { name: /^选择模型/ }) 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() await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() @@ -143,6 +144,7 @@ describe('web e2e: the composer model switch is the default for later sessions', await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) const unset = page.getByRole('button', { name: '请选择模型', exact: true }) await unset.waitFor() + expect(await unset.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) @@ -161,7 +163,6 @@ describe('web e2e: the composer model switch is the default for later sessions', const seat = page.getByRole('button', { name: '请选择模型', exact: true }) expect(await seat.isEnabled()).toBe(true) await seat.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio').first().click() await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 2b7317e90e..db4fe73951 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 9b73298b776d2f3d869a5da88db8d17e14f0f3a9 -README.zh.md: 4dbd077a30cbbaaefd9fabcbf1cc4661367a3069 +README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 +README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 9b73298b77..3c73a42b34 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -27,6 +27,8 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. +The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. + Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. ### Model and effort diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 4dbd077a30..87cccdeacd 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -27,6 +27,8 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 +未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 + 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 ### 模型与推理强度 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 799c10776c..3f42221fca 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -3,7 +3,7 @@ min-width: 0; } -/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap, +/* Figma 313:14108 ToggleButton: 13/20 regular secondary label, 4px gap, 12px caption chevron; 28px chip height matches the sibling Plan / Read-only selects in the same tool row. */ .trigger { @@ -25,7 +25,7 @@ color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; - font-weight: 500; + font-weight: 400; cursor: pointer; } @@ -49,7 +49,7 @@ white-space: nowrap; } -/* Effort value beside the model name (mock's 'High': same 13/20/500, caption +/* Effort value beside the model name (mock's 'High': same 13/20/400, caption tone). Adaptive priority: the huge shrink factor makes the effort absorb all deficit first — it ellipsizes away to nothing before the model name loses a single pixel, and the chevron stays snug throughout. */ diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 5e33fbe526..2223cd3c21 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -96,7 +96,7 @@ export function ModelSelect( const reasoning = currentChoice?.model.reasoning const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort const effortLabel = reasoning === undefined - ? undefined + ? state.retainedEffort : effectiveEffort === undefined ? t('effort.providerDefault') : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort @@ -192,7 +192,8 @@ export function ModelSelect( if (!available) return null const show = (): void => { - setPane('root') + if (state.current === null) paneFocus.current = 'drill' + setPane(state.current === null ? 'model' : 'root') setOpen(true) reload() } @@ -231,7 +232,7 @@ export function ModelSelect( if (event.key === 'Escape' && open) { event.preventDefault() // Escape backs out of a drilled pane first, then closes. - if (pane !== 'root') back(pane) + if (pane !== 'root' && state.current !== null) back(pane) else close(true) return } @@ -242,7 +243,7 @@ export function ModelSelect( if (event.key === 'Tab') { if (event.shiftKey) { event.preventDefault() - if (pane !== 'root') back(pane) + if (pane !== 'root' && state.current !== null) back(pane) else close(true) return } diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index d5073d2ab0..cbc92642e7 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,7 +1,7 @@ /** One Host-generation model catalog shared by every Session selector. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelCatalog, ModelSelection, ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -20,6 +20,17 @@ export class ModelCatalogDirectory { error: null, }) + private readonly reasoning = new Map() + + /** + * Read the last advertised reasoning metadata, including unavailable models. + * @param selection - provider and model whose effort is displayed. + * @returns reasoning metadata observed during this Host generation. + */ + reasoningFor(selection: ModelSelection): ModelProviderGroup['models'][number]['reasoning'] { + return this.reasoning.get(JSON.stringify([selection.provider, selection.model])) + } + private generation = 0 private inflight: Promise | undefined @@ -47,6 +58,11 @@ export class ModelCatalogDirectory { throw new Error(`${response.error.code}: ${response.error.message}`) } if (generation === this.generation) { + for (const group of response.value.groups) { + for (const model of group.models) { + this.reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning) + } + } this.store.set({ value: response.value, status: 'ready', error: null }) } return response.value @@ -84,6 +100,7 @@ export class ModelCatalogDirectory { /** Clear Host-specific values and load the replacement Host generation. */ resetGeneration(): void { + this.reasoning.clear() this.invalidate(true) void this.load().catch(() => { /* the selector exposes the shared error */ }) } diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 8e03bcf206..162ad5db7d 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -17,6 +17,8 @@ import type { ModelCatalogDirectory } from './catalog.ts' export interface ModelDirectoryState { /** Available durable selection, or the available default for an unselected Session. */ current: ModelSelection | null + /** Saved effort caption retained when the selected model is unavailable. */ + retainedEffort?: string /** Whether the current selection is present in the available catalog; null while unresolved. */ routable: boolean | null /** Successfully loaded provider groups (last good load). */ @@ -136,9 +138,15 @@ export class ModelDirectory { if (this.disposed) return const catalog = this.catalog.store.getSnapshot() const projected = modelSelectionProjection(this.projected.getSnapshot()) + const intended = projected?.next ?? catalog.value?.default + const reasoning = intended === undefined ? undefined : this.catalog.reasoningFor(intended) + const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort + const retainedEffort = effort === undefined ? undefined + : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ current: null, + ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, groups: [], failures: [], @@ -147,11 +155,12 @@ export class ModelDirectory { }) return } - const intended = projected.next ?? catalog.value.default - const current = catalog.value.groups.some(group => group.id === intended.provider - && group.models.some(model => model.id === intended.model)) ? intended : null + const selection = projected.next ?? catalog.value.default + const current = catalog.value.groups.some(group => group.id === selection.provider + && group.models.some(model => model.id === selection.model)) ? selection : null this.store.set({ current, + ...retainedEffort === undefined ? {} : { retainedEffort }, routable: current !== null, groups: catalog.value.groups, failures: catalog.value.failures, diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 4fa513e9ba..af7d3dee20 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -414,6 +414,29 @@ describe('ui-model-selection dual entry', () => { } }) + it('retains saved effort for existing and new sessions after credentials disappear', async () => { + const b = await bench() + try { + b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }) + b.mint('existing') + const existing = b.ctx.modelDirectories.directoryFor(sid('existing')) + await existing.load() + b.setProjected(sid('existing'), { lastUsed: null, + next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' } }) + b.setRoutable(false) + b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(existing.store.getSnapshot().retainedEffort).toBe('High') + await vi.waitFor(() => { + expect(existing.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'High' }) + }) + b.mint('new') + const fresh = b.ctx.modelDirectories.directoryFor(sid('new')) + expect(fresh.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'Max' }) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('blocks the composer when current catalog models disappear', async () => { const b = await bench() b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt index d6d4beb3b1..2e251b01d4 100644 --- a/packages/client/ui-model-selection/tests/expected/unselected-model.txt +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -1 +1 @@ -请选择模型 +请选择模型High diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index e71062bbdc..2d8df4fad0 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -447,13 +447,19 @@ describe('ModelSelect keyboard walk', () => { }) }) -it('shows the unselected model control without a stale model or effort', async () => { - const directory = createSnapshotStore(state({ current: null, routable: false })) +it('shows the unselected model control with the inherited effort', async () => { + const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() const trigger = screen.getByRole('button', { name: '请选择模型' }) expect(trigger.hasAttribute('disabled')).toBe(false) await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') - expect(trigger.textContent).not.toContain('High') + expect(trigger.textContent).toContain('High') + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() + const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(model) + fireEvent.keyDown(model, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() }) @@ -464,7 +470,6 @@ it('places account models first while preserving other provider and model order' const directory = createSnapshotStore(state({ current: null, groups })) render() fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', From 0d2fbaad67707f95b3281a1e39340c606283246a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:27:35 +0800 Subject: [PATCH 016/253] fix(session-controller,ui-model-selection,ui-settings): handle catalog and account setup failures --- apps/web/tests/default-model.e2e.ts | 6 +-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 ++-- docs/event-producer-consumer.zh.md | 10 ++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 8 ++-- docs/subsystems/llm-streaming.zh.md | 8 ++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/catalog.ts | 17 +++++-- packages/api/session-controller/src/index.ts | 3 +- packages/api/session-controller/src/types.ts | 1 + .../tests/session-models.host.spec.ts | 46 +++++++++++++++++- .../client/skeleton/ConversationContent.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 7 ++- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.tsx | 7 +-- .../src/client/directory.ts | 8 ++-- .../ui-model-selection/src/client/index.ts | 5 +- .../ui-model-selection/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 48 ++++++++++++++++++- .../tests/expected/account-first.txt | 4 +- .../tests/model-select.client.spec.tsx | 21 ++++++-- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 4 +- .../client/ui-settings-account/README.zh.md | 4 +- .../src/client/AccountMenu.tsx | 4 +- .../src/client/SignOutDialog.tsx | 4 +- .../ui-settings-account/src/client/index.ts | 22 ++++----- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 6 +-- .../tests/apply.client.spec.ts | 26 ++++++++-- .../tests/expected/sign-out-unknown-en.txt | 1 + .../tests/expected/sign-out-unknown-zh.txt | 1 + .../tests/sign-out-dialog.client.spec.tsx | 6 +-- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 ++ .../client/ui-settings-models/README.zh.md | 4 ++ .../src/client/CustomProviderCard.tsx | 3 +- .../src/client/ProviderEditor.tsx | 3 +- .../src/client/operations.ts | 15 ++++-- .../ui-settings-models/src/client/store.ts | 6 ++- .../tests/components.client.spec.tsx | 23 +++++++++ .../tests/provider-form.client.spec.tsx | 24 ++++++++++ .../tests/store.client.spec.ts | 10 ++++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 + packages/llm/llm/README.zh.md | 2 + packages/llm/llm/src/index.ts | 8 ++-- 63 files changed, 348 insertions(+), 123 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 40b3911071..45975fe2df 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -132,10 +132,8 @@ describe('web e2e: the composer model switch is the default for later sessions', const box = page.locator('[data-composer-input]').first() await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) - // What removing the provider on the Models page leaves behind: the saved - // default still names the route, and nothing serves it any more. - // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every - // stored profile in place. + // Removing the selected provider preserves the other route and the saved + // selection. A merge update would retain the removed profile. await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d642a66c82..d1ac605308 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3e651fa4433f0d6bab0ba73cd85929ed45e9ee79 -architecture.zh.md: a30bb804c419f38e3f68a06c252e90e4993b9e42 +architecture.md: fd3e04f7287ce97628b162becf647284221c8f64 +architecture.zh.md: f5e3bf8a3a6746044d5fbd229997eeb21303576c diff --git a/docs/architecture.md b/docs/architecture.md index 3e651fa443..fd3e04f728 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -Immutable requests retain cancellation. `Agent.activeProvider` exposes current provider ownership. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +The loop sends immutable requests with live cancellation and reuses message-freeze evidence only for identities it fully froze; [agent-loop](../packages/core/agent-loop/README.md) owns request construction. `Agent.activeProvider` identifies the provider of the latest prepared request. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index a30bb804c4..f5e3bf8a3a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 `Agent.activeProvider` 暴露当前提供方归属。 +循环发送不可变请求并保留实时取消能力,只对已完整冻结的消息对象身份复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。`Agent.activeProvider` 标识最近一次已准备请求的提供方。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e174fcd03e..ba7cde7178 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 -event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 +event-producer-consumer.md: f8160835916b5f1e6e71583c2adf647e4d996316 +event-producer-consumer.zh.md: 7a17b855917b7cb35d0fe67a56a9040dcd615978 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c5535c610e..f816083591 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b2e558f5e7..7a17b85591 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..d59f81da1a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: f71cb89c5494c77df8d036faddea811a110444e1 +persistence-catalog.zh.md: abd0280d094b9f24fea7d9abe59d2ad17af263c3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..f71cb89c54 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5304,7 +5304,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..abd0280d09 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..db3a0f152a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44144,7 +44144,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:381", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +57727,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:386" ] }, { diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 757474dd41..2a34dc3c29 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 07fc6599bc5d4eb5a4af9b566032004932052585 -llm-streaming.zh.md: 079a33599d1e5169af6402db2b2abdd7dc978369 +llm-streaming.md: 6ff93698cb877591fb85bc1a05d1dabc95b6561b +llm-streaming.zh.md: 400fcd5a96de0097ec827055fe6f96fc3eef0846 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 07fc6599bc..6ff93698cb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -837,8 +837,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1014,7 +1015,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 079a33599d..400fcd5a96 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -843,8 +843,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1020,7 +1021,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index b178f956cf..c7614d54c7 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 5f09de61395c3d729144203b77eeb299c217128e -README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d +README.md: 5b96af2040c95e6e9195d55207ead0e0acbdba12 +README.zh.md: 42961830f1731ad9ce3efe048ebde03490f80637 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5f09de6139..5b96af2040 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection or a failed catalog query rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation with the same error code. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. These catalog requirements belong to GUI admission; direct LLM calls can use uncatalogued models. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index dfb5b97b58..42961830f1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。选择不可用或目录查询失败时,投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查,并使用相同错误码。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。这些目录要求属于 GUI 准入;直接 LLM 调用可使用未列入目录的模型。 ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index df751d86ba..e66c6961cb 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -1,5 +1,7 @@ /** Shared projection of the live LLM registry into the browser model catalog. */ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { LlmModelInfo } from '@deepseek-ai/dsh-llm' import type { Context } from '@deepseek-ai/cordis' import type { ModelCatalog, @@ -57,11 +59,12 @@ export async function buildModelCatalog( } } })) + const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + .filter(group => group.models.length > 0) return { default: { ...defaultSelection }, - routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), - groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) - .filter(group => group.models.length > 0), + routableProviders: groups.map(group => group.id), + groups, failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } @@ -74,6 +77,12 @@ export async function buildModelCatalog( */ export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false - const models = await ctx.llm.listModels(selection.provider) + let models: readonly LlmModelInfo[] + try { models = await ctx.llm.listModels(selection.provider) } + catch (error) { + throw new RemoteError('session/model-unavailable', + error instanceof Error ? error.message : String(error), + { provider: selection.provider, model: selection.model }) + } return models.some(model => model.id === selection.model) } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 6f51c15da4..2cc81e3097 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -279,7 +279,8 @@ export class SessionController extends TypertRemoteService { async initializeDefaultModel(provider: string): Promise { const catalog = await buildModelCatalog(this.ctx) const model = catalog.groups.find(group => group.id === provider)?.models[0] - if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + if (model === undefined) throw new RemoteError('session/provider-models-unavailable', + `provider "${provider}" has no available models`, { provider }) await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c5b92ec0cc..e1021e8c0e 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -187,6 +187,7 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } 'session/conflict': { readonly sessionId: SessionId diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 26d71d839e..0994ed1474 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -360,7 +360,9 @@ describe('Web session model selection', () => { await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) await controller.initializeDefaultModel('deepseek-official') expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) - await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({ + code: 'session/provider-models-unavailable', details: { provider: 'empty' }, + }) await ctx.fiber.dispose() }) @@ -612,6 +614,48 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it.each([1, 2])('reports catalog errors as model-unavailable at prompt check %s', async (check) => { + const { ctx, sessionId, agent } = await harness() + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const list = vi.spyOn(ctx.llm, 'listModels') + if (check === 2) list.mockResolvedValueOnce([{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'Chat' }]) + list.mockRejectedValueOnce(new Error('credential storage offline')) + try { + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [{ type: 'text', text: 'hello' }], + }))).toMatchObject({ ok: false, error: { + code: 'session/model-unavailable', message: 'credential storage offline', + details: { provider: 'deepseek-official', model: 'deepseek-chat' }, + } }) + expect(list).toHaveBeenCalledTimes(check) + expect(followup).not.toHaveBeenCalled() + } finally { + list.mockRestore() + await ctx.fiber.dispose() + } + }) + + it('leaves uncatalogued adapters usable by core callers but unavailable to GUI selection', async () => { + const { ctx, sessionId } = await harness() + ctx.llm.registerAdapter(['uncatalogued'], new class extends LlmAdapter { + override async *stream(_options: GenerateOptions): AsyncIterable {} + }()) + try { + await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', + }) + expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + } finally { + await ctx.fiber.dispose() + } + }) + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { const { ctx, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 42bc9724df..dc7e753c88 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } + ? { blocked: composerBlock, placeholder: t(hero ? 'placeholder.hero' : 'placeholder.default') } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..c7720a7db2 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -424,15 +424,14 @@ describe('ConversationRoot resident composer', () => { expect(dispatchCount()).toBe(before) }) - it('renders the composer inert with the blocker\u2019s own reason', () => { + it('keeps the normal placeholder while model availability blocks sending', () => { const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') - // One disabled composer with the blocker's placeholder, never a second - // tree: the DOM survives the block being raised and cleared. + // Blocking keeps the mounted composer and its normal placeholder. expect(box.getAttribute('aria-disabled')).toBe('true') - expect(box.getAttribute('data-placeholder')).toBe('select a model first') + expect(box.getAttribute('data-placeholder')).toBe(zh['placeholder.default']) fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index db4fe73951..496f41b644 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 -README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be +README.md: 25945f012b2c1f6cfc0f36203fac632c01a86101 +README.zh.md: 6c59586a08b7833f40491f61b5f57cd2b76e5735 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 3c73a42b34..25945f012b 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -33,11 +33,11 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions -The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Refreshes and refresh failures retain the last displayed selection and groups while sending remains blocked; retry revalidates availability. A Host reset clears that display. The account provider name follows the active locale in both selectors. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 87cccdeacd..6c59586a08 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -33,11 +33,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 -确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。刷新及刷新失败期间保留上次显示的选择和分组,同时继续禁发;重试会重新确认可用性。Host 重置时清空这些显示。两个选择器中的账号提供方名称均随当前语言显示。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 2223cd3c21..d209e23cc7 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -76,7 +76,8 @@ export function ModelSelect( const id = useId() const groups = useMemo(() => state.groups.toSorted((left, right) => - Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups]) const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, @@ -407,7 +408,7 @@ export function ModelSelect( )} {state.failures.map(failure => (
- {t('warning.groupLoad', { name: failure.name, message: failure.message })} + {t('warning.groupLoad', { name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, message: failure.message })}
))} @@ -416,7 +417,7 @@ export function ModelSelect( const headingId = `${id}-${group.id}` return (
-
{group.name}
+
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
{group.models.map((model) => { const selected = state.current?.provider === group.id && state.current.model === model.id return ( diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 162ad5db7d..355dfc7fda 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,7 +15,7 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Available durable selection, or the available default for an unselected Session. */ + /** Last catalog-confirmed selection, retained for display during same-Host refreshes. */ current: ModelSelection | null /** Saved effort caption retained when the selected model is unavailable. */ retainedEffort?: string @@ -145,11 +145,11 @@ export class ModelDirectory { : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ - current: null, + current: catalog.value === null ? null : this.store.getSnapshot().current, ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, - groups: [], - failures: [], + groups: catalog.value?.groups ?? [], + failures: catalog.value?.failures ?? [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error, }) diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 201ac8c732..12f81cec2f 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -67,12 +67,13 @@ function descriptionOf( function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] for (const group of directory.groups) { + const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { const description = descriptionOf(group.id, model, t) rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: description !== undefined ? `${group.name} · ${description}` : group.name, + detail: description !== undefined ? `${name} · ${description}` : name, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -83,7 +84,7 @@ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): Sel for (const failure of directory.failures) { rows.push({ id: `failure/${failure.id}`, - label: failure.name, + label: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, detail: t('option.loadError', { message: failure.message }), }) } diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2ab727b353..9d60b95926 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -10,6 +10,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'provider.account': 'DeepSeek 账号', 'command.label': '模型', 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', @@ -39,6 +40,7 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'provider.account': 'DeepSeek Account', 'command.label': 'Model', 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index af7d3dee20..ffd02ee21b 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -73,16 +73,19 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const projections = new Map>() // Whether the Host advertises available models for the current route. let routable = true + let catalogFailure = false + let groups = GROUPS let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { modelCatalog: () => { calls.models += 1 + if (catalogFailure) return Promise.reject(new Error('catalog offline')) return Promise.resolve({ ok: true as const, value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: routable ? GROUPS : [], + groups: routable ? groups : [], failures: [], }, }) @@ -185,6 +188,8 @@ async function bench(locale: 'zh' | 'en' = 'zh') { setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) }, address: (id: SessionId) => { addressed.add(id) }, + setGroups: (next: typeof GROUPS) => { groups = next }, + setCatalogFailure: (next: boolean) => { catalogFailure = next }, setRoutable: (next: boolean) => { routable = next }, blockOf: (key: string) => blocks.get(sid(key)), } @@ -280,6 +285,19 @@ describe('ui-model-selection dual entry', () => { }) }) + it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + const b = await bench(locale) + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') @@ -330,7 +348,9 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: null, + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, + routable: null, status: 'loading', }) @@ -342,6 +362,30 @@ describe('ui-model-selection dual entry', () => { }) }) + it('retains the last catalog on refresh failure, blocks sending, and recovers on retry', async () => { + const b = await bench() + try { + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + b.setCatalogFailure(true) + b.remote.emit('credentials/record-updated', ['DEEPSEEK_API_KEY']) + await vi.waitFor(() => { + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, routable: null, status: 'error', error: 'catalog offline', + }) + }) + expect(b.blockOf('s1')).toBeDefined() + b.setCatalogFailure(false) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + expect(face.directory.getSnapshot().routable).toBe(true) + expect(b.blockOf('s1')).toBeUndefined() + } finally { + await b.ctx.fiber.dispose() + } + }) + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { const b = await bench() const first = b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt index 304c901c9f..6a8a6e2c70 100644 --- a/packages/client/ui-model-selection/tests/expected/account-first.txt +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -1,8 +1,8 @@ deepseek-account-1 deepseek-account-2 -custom-1 -custom-2 deepseek-official-1 deepseek-official-2 +custom-1 +custom-2 another-1 another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 2d8df4fad0..feef74b1bd 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // The seat's key domain is model ∪ common; the stub mirrors the real lookup @@ -463,7 +463,7 @@ it('shows the unselected model control with the inherited effort', async () => { }) -it('places account models first while preserving other provider and model order', async () => { +it('places account and official models before third-party models', async () => { const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), })) @@ -472,9 +472,22 @@ it('places account models first while preserving other provider and model order' fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ - 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', - 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + 'deepseek-account-1', 'deepseek-account-2', 'deepseek-official-1', 'deepseek-official-2', + 'custom-1', 'custom-2', 'another-1', 'another-2', ]) expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') }) + +it.each([en, zh])('localizes the account group while preserving external names', (copy) => { + const groups = ['deepseek-account', 'custom'].map(id => ({ + id, name: id === 'deepseek-account' ? 'DeepSeek Account' : 'My Gateway', + models: [{ id: 'model', name: 'Model' }], + })) + render( key in copy ? copy[key as keyof typeof copy] : key} />) + fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] })) + expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy() + expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy() +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a9fcd71d5a..a3c8fbdf6f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: d54687d083897842518a0c95dfc84d39e985f3a1 -README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb +README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 +README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index d54687d083..615d2ef346 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -34,7 +34,7 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a The account card’s More account information link opens `https://platform.deepseek.com` in the system browser on Desktop or a new tab on Web. -Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed queries keep the menu available; failed sign-out keeps the dialog available for retry. +Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry. ## Understand the implementation @@ -74,3 +74,5 @@ No model request prefix changes. The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership. Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553. + +Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index c4dbcf234f..39d8a94b2e 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -34,7 +34,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号卡片的“更多账号信息”链接在 Desktop 中通过系统浏览器打开 `https://platform.deepseek.com`,在 Web 中打开新标签页。 -退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。查询失败保留菜单;退登失败保留弹窗以便重试。 +退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。 ## 理解实现 @@ -74,3 +74,5 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl [桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。 用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。 + +默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 6122728558..7570a13190 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -27,12 +27,12 @@ export function AccountMenu({ const [open, setOpen] = useState(false) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) - const [signOutImpact, setSignOutImpact] = useState() + const [signOutImpact, setSignOutImpact] = useState() const requestSignOut = async () => { setBusy(true) setFailed(false) try { setSignOutImpact(await hasRunningAccountTasks()); setOpen(false) } - catch { setFailed(true) } + catch (_error) { setSignOutImpact('unknown'); setOpen(false) } finally { setBusy(false) } } return
diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index f9b59e8ea8..d364f41ede 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -6,7 +6,7 @@ import css from './SignInDialog.module.css' /** @param props - task impact at opening, localized copy, and account actions. @returns sign-out confirmation. */ export function SignOutDialog({ running, signOut, close, t }: { - running: boolean + running: boolean | 'unknown' signOut: () => Promise close: () => void t: (key: AccountKey) => string @@ -29,7 +29,7 @@ export function SignOutDialog({ running, signOut, close, t }: {
-

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+

{t(running === 'unknown' ? 'signOutUnknownDescription' : running ? 'signOutRunningDescription' : 'signOutDescription')}

{failed &&

{t('failed')}

}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index da26bbd8d8..c718292cbf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -72,19 +72,19 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - let initializationFailed = false - if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded') { - try { - const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') - initializationFailed = !initialized.ok - } catch (_error) { - initializationFailed = true - } - } - publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) browser.update(frame.value) frame.accept() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() void refresh() } })().catch(() => { if (!stream.signal.aborted) publish({ ...snapshot, failed: true }) }) diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index b93c8e61c0..e3a07f3f8f 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -10,6 +10,7 @@ export const en = { settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', signIn: 'Sign in', signOut: 'Sign out', + signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?', signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', @@ -36,6 +37,7 @@ export const zh: Record = { settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', signIn: '登录', signOut: '退出登录', + signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?', signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 3757579a79..dbf81ece0e 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -51,14 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} + hasRunningAccountTasks={async () => { if (running === 'unknown') throw new Error('offline'); return running }} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -73,7 +73,7 @@ it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running } fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) expect(signOut).not.toHaveBeenCalled() - expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + expect(screen.getByText(running === 'unknown' ? copy.signOutUnknownDescription : running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 7f01fc5f6e..6a33cda9d4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -4,13 +4,17 @@ import { expect, it, vi } from 'vitest' import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import { apply, type AccountSectionInjected } from '../src/client/index.ts' -it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { +it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const observed: { status: string | undefined; failed: boolean | undefined }[] = [] const abort = new AbortController() const done = Promise.withResolvers() const release = Promise.withResolvers() + const initialization = Promise.withResolvers() const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'pending') await initialization.promise if (outcome === 'disconnected') throw new Error('connection lost') - return { ok: outcome === 'accepted', value: undefined } + return { ok: outcome === 'accepted' || outcome === 'pending', value: undefined } }) const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } const signedIn: AccountView = { @@ -38,7 +42,11 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m $stream: () => ({ signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, async *[Symbol.asyncIterator]() { - for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + for (const value of frames) yield { value, accept: () => { + const snapshot = operations?.hooks.account.getSnapshot() + observed.push({ status: snapshot?.view?.status, failed: snapshot?.failed }) + if (value.status === 'signed-out') done.resolve(undefined) + } } await release.promise }, }), @@ -48,8 +56,20 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m apply(ctx as never) await done.promise expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(observed).toEqual([ + { status: 'credential-stored', failed: false }, + { status: 'credential-stored', failed: false }, + { status: 'signed-out', failed: false }, + ]) + if (outcome === 'accepted' || outcome === 'pending') expect(log).not.toHaveBeenCalled() + else expect(log).toHaveBeenCalledWith('[deepseek-account] default model initialization failed', { + reason: outcome === 'refused' ? 'refused' : 'disconnected', + }) expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) } finally { + initialization.resolve(undefined) + await Promise.resolve() for (const dispose of disposers.reverse()) dispose() + log.mockRestore() } }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt new file mode 100644 index 0000000000..d8b1fd328b --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt @@ -0,0 +1 @@ +Sign outCould not check running tasks. Signing out may interrupt tasks using this account. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt new file mode 100644 index 0000000000..3578b1f9bf --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt @@ -0,0 +1 @@ +退出登录暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx index 9ee95e897f..d2fbb29028 100644 --- a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -6,16 +6,16 @@ import { SignOutDialog } from '../src/client/SignOutDialog.tsx' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { +function mount(running: boolean | 'unknown', copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { const close = vi.fn() render( copy[key]} />) return { signOut, close } } -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))( 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { const props = mount(running, copy) await expect(`${screen.getByRole('dialog').textContent}\n`) - .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + .toMatchFileSnapshot(`./expected/sign-out-${running === 'unknown' ? 'unknown' : running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) expect(props.signOut).not.toHaveBeenCalled() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(props.signOut).toHaveBeenCalledOnce() diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c84f99abf1..3147eeab54 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 -README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f +README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf +README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index ccce6981a6..289a0ec0b3 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -25,6 +25,10 @@ English | [中文](README.zh.md) ## Use this package +Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer. + +DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order. + Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another. A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0d2f64a1fe..10c61a464d 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -25,6 +25,10 @@ kind: "package-reference" ## 使用本包 +凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。 + +提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。 + 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index c84e72f743..38def6a132 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return operations.initializeModel(route.trim()) + void operations.initializeModel(route.trim()) + return undefined } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 93e1fa7d1e..a91cc0d744 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -288,8 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored - const initialized = await operations.initializeModel(props.provider) - if (initialized !== undefined) return initialized + void operations.initializeModel(props.provider) } setKeyDraft('') return undefined diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 75d87ac821..071ab72454 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,8 +32,11 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ - initializeModel(provider: string): Promise + /** + * @param provider - newly configured provider. + * @returns after optional initialization; failures are logged without changing the save outcome. + */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -84,8 +87,12 @@ export interface ModelsOperations { export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { initializeModel: async (provider) => { - const response = await ctx.remote.session.initializeDefaultModel(provider) - return response.ok ? undefined : response.error.message + try { + const response = await ctx.remote.session.initializeDefaultModel(provider) + if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' }) + } catch (_error) { + console.info('[models] default model initialization failed', { provider, reason: 'disconnected' }) + } }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f3e948b79f..f693b5960d 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -37,7 +37,7 @@ export interface ProviderDirectoryEntry { * Join declared configurable providers with the currently registered routes. * @param registered - live provider routes in registration order. * @param directory - declared configurable providers in declaration order. - * @returns declared rows followed by live routes with no declaration. + * @returns account and official routes first, then other routes in their original order. */ export function joinProviderDirectory( registered: readonly LlmProviderInfo[], @@ -64,7 +64,9 @@ export function joinProviderDirectory( active: true, }) } - return rows + return rows.toSorted((left, right) => + (left.provider === 'deepseek-account' ? 0 : left.provider === 'deepseek-official' ? 1 : 2) + - (right.provider === 'deepseek-account' ? 0 : right.provider === 'deepseek-official' ? 1 : 2)) } /** One provider row the page renders. */ diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 88e43e599f..c1752b9204 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -1111,6 +1111,29 @@ describe('ModelsSection', () => { expect(baseURL.value).toBe('') }) + it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const pending = Promise.withResolvers() + const { ctx, set } = await mountDeepSeekCard() + const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel') + if (outcome === 'pending') initialize.mockReturnValue(pending.promise) + else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline')) + else initialize.mockResolvedValue({ ok: false, + error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) }) + try { + fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() } + initialize.mockRestore() + log.mockRestore() + } + }) + it('rejects an invalid draft before writing', async () => { const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index ae71cf0557..370027cdd7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -847,6 +847,30 @@ describe('hand-declared providers', () => { return { ...scripted, onClose } } + it('closes a saved custom provider when default initialization is refused', async () => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const { face, mutate, set, onClose } = mountCard() + const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel') + .mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable', + 'empty catalog', { provider: 'acme-gateway' }) }) + try { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + initialize.mockRestore() + log.mockRestore() + } + }) + it('writes the whole profile and the key under the derived reference', async () => { const { mutate, set, onClose } = mountCard() diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index d181edc12e..5a56062da4 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -16,6 +16,16 @@ it.each([false, true])('retains configuration diagnostics when the route is acti }]) }) +it('places account and official before third-party providers', () => { + const providers = ['custom', 'deepseek-official', 'deepseek-account', 'openai'] + const directory = providers.map(provider => ({ + provider, displayName: provider, settingsNs: 'fixture', settingsPath: [], + })) + expect(joinProviderDirectory([], directory).map(row => row.provider)) + .toEqual(['deepseek-account', 'deepseek-official', 'custom', 'openai']) + expect(directory.map(row => row.provider)).toEqual(providers) +}) + let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5706f2a77d..f05c4ed4f6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1398,7 +1398,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async listModels(provider: string): Promise', - description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', + description: 'Discover models advertised by one registered provider. Catalog membership does not constrain core routing. Catalog-driven entry points may restrict selection and submission to the advertised models.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], returns: 'detached model metadata in adapter-preferred order.', }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index fcc298d024..8bfe705158 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee -README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 +README.md: be2694c0f7cd1fd19b7f17cabb0e2598e995a065 +README.zh.md: e07afc9f9084c04bd0605de284e40c4f8c44b445 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7ded16b5e1..be2694c0f7 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,7 +47,7 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s filesApiTimeoutMs: 60000 ``` -A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. +A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection and submission require a catalog entry. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. | Field | Default | Meaning | |---|---|---| diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index de612f7b21..e07afc9f90 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" filesApiTimeoutMs: 60000 ``` -请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 +请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择与提交要求模型具有目录条目。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 | 字段 | 默认值 | 含义 | |---|---|---| diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..0adf149bab 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 1abdc8e332410ccd69066af6944bc4a32221635e +README.zh.md: 320a232c13fd145b65d53860c0cb1a4f7bfeb5a7 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..1abdc8e332 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,6 +25,8 @@ Use `@deepseek-ai/dsh-llm` to stream model calls through configured provider ada ## Use this package +`listModels` describes models offered by catalog-driven interfaces. Core resolution and streaming can still accept unlisted ids. The GUI requires catalog membership for selection and submission; adapters intended for GUI use must implement `listModels` and advertise their available models. The base implementation returns an empty list and therefore offers no GUI models. + Any composition that calls a model provider — an agent loop, a session-title generator, a compaction summarizer — streams its requests through this service. Mount it together with at least one provider adapter; the service itself has no configuration and no provider wire code. ### When to choose it diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..320a232c13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +`listModels` 描述目录驱动界面提供的模型。核心解析与流式调用仍可接受未列出的 ID。GUI 的模型选择与提交要求模型出现在目录中;供 GUI 使用的适配器必须实现 `listModels`,公布其可用模型。基类实现返回空列表,因此不向 GUI 提供模型。 + 任何调用模型提供方的组合——agent loop(智能体循环)、会话标题生成器、压缩(compaction)摘要器——都会通过本服务流式发起请求。与至少一个提供方适配器一起挂载它;服务本身没有任何配置,也不包含提供方协议代码。 ### 何时选择 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 5f633619ff..64252cb643 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -235,8 +235,9 @@ export abstract class LlmAdapter { /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -689,7 +690,8 @@ export class LlmRuntime extends TypertRemoteService { /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ From d685383ee64336517f80daf26f6ee7e4828a874f Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:39:34 +0800 Subject: [PATCH 017/253] fix(native-command): resolve packaged Windows app icons Windows 11 ships some applications (Notepad, other Store apps) as MSIX packages whose Shell handler reports an indirect icon reference such as @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png} instead of a file path. SHDefExtractIcon cannot read that reference, so the menu showed the generic fallback glyph. Resolve an indirect reference with SHLoadIndirectString before extracting, and fall back to the handler's executable when the resolved source still yields no image. --- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../src/file-applications-windows.ts | 39 +++++++++++++++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index e0280c9599..317286a9d9 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: 2ae2fd047e6359428debfe9252dc87146b646596 -README.zh.md: 62bb667af4705aac1373d9d0dda809dbab60e7da +README.md: 1d42f4f15b4d36a09dfb76db7e951d4ec217df86 +README.zh.md: e9a0f690c556ce963635047f6d14dc4fbe473437 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 2ae2fd047e..1d42f4f15b 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -49,7 +49,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. -`nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers; Linux uses GIO with shared XDG desktop-entry and icon lookup. `openNativeFileApplication(path, application, signal)` revalidates the handler without changing the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. +`nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers and resolves a packaged application’s indirect icon reference before extraction; Linux uses GIO with shared XDG desktop-entry and icon lookup. `openNativeFileApplication(path, application, signal)` revalidates the handler without changing the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 62bb667af4..e9a0f690c5 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -49,7 +49,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 -`nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。`openNativeFileApplication(path, application, signal)` 重新验证关联应用,不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 +`nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器并在提取前解析打包应用的间接图标引用,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。`openNativeFileApplication(path, application, signal)` 重新验证关联应用,不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 ----- diff --git a/packages/util/native-command/src/file-applications-windows.ts b/packages/util/native-command/src/file-applications-windows.ts index a468c9979c..c9950875cd 100644 --- a/packages/util/native-command/src/file-applications-windows.ts +++ b/packages/util/native-command/src/file-applications-windows.ts @@ -41,6 +41,8 @@ public static class DshFileAssociations { static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size); [DllImport("user32.dll")] static extern bool DestroyIcon(IntPtr icon); + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + static extern int SHLoadIndirectString(string source, StringBuilder output, uint size, IntPtr reserved); [DllImport("shlwapi.dll", PreserveSig = false)] static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference); @@ -118,12 +120,18 @@ public static class DshFileAssociations { var text = new StringBuilder((int)size); return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null; } - static string IconData(IHandler handler) { + // Resolve a packaged application's indirect icon reference, for example + // @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png}, to + // the resource file the Shell would draw; an unresolved reference is null. + static string ResolveIndirectIcon(string source) { + var output = new StringBuilder(1024); + return SHLoadIndirectString(source, output, (uint)output.Capacity, IntPtr.Zero) == 0 ? output.ToString() : null; + } + // Read one icon source into a 32px PNG: an image file directly, otherwise the + // resource the Shell extracts at the given index. A missing resource is null. + static string PngFromIconSource(string source, int index) { IntPtr large = IntPtr.Zero, small = IntPtr.Zero; try { - string source; int index; - handler.GetIconLocation(out source, out index); - source = Environment.ExpandEnvironmentVariables(source); if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) { using (var original = Image.FromFile(source)) using (var resized = new Bitmap(original, new Size(32, 32))) @@ -147,6 +155,29 @@ public static class DshFileAssociations { if (small != IntPtr.Zero) DestroyIcon(small); } } + static string IconData(IHandler handler) { + string source = null; int index = 0; + try { handler.GetIconLocation(out source, out index); } catch (Exception) { + // A handler without a drawable icon location falls back to its executable. + } + if (!String.IsNullOrEmpty(source)) { + source = Environment.ExpandEnvironmentVariables(source); + if (source.StartsWith("@{", StringComparison.Ordinal) && source.EndsWith("}", StringComparison.Ordinal)) { + var resolved = ResolveIndirectIcon(source); + if (!String.IsNullOrEmpty(resolved)) source = resolved; + } + var icon = PngFromIconSource(source, index); + if (icon != null) return icon; + } + // Packaged handlers name the executable only in GetName; extract there too. + string executable = null; + try { handler.GetName(out executable); } catch (Exception) { + // A handler that names no executable has no icon to fall back to. + } + return String.IsNullOrEmpty(executable) || String.Equals(executable, source, StringComparison.OrdinalIgnoreCase) + ? null + : PngFromIconSource(executable, 0); + } static void Visit(string path, Action visit) { var extension = Path.GetExtension(path); if (extension.Length == 0) return; From 6fa5151d8a7cea9433b570861f7a9458bc0dc151 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:39:38 +0800 Subject: [PATCH 018/253] fix(ui-open-in-app): keep an application as the default file action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host's default marker is best effort: on Windows it comes from the Shell's ASSOCSTR_EXECUTABLE/APPID answer, which does not always name an enumerated handler. When no application carried that marker the split button promoted 显示文件位置 to the primary action even though the menu listed applications that can open the file. Select the OS-marked default when present, otherwise the first registered handler in Shell preference order, and mark it as the menu default. Reveal is promoted only when no application is registered at all. --- .../client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 2 +- packages/client/ui-open-in-app/README.zh.md | 2 +- .../src/client/OpenTargetButton.tsx | 13 +++++++--- .../tests/open-path-action.client.spec.tsx | 24 ++++++++++++------- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index b0fa75db3e..4ceb2983f9 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md -README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 -README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 +README.md: 4f8462b29579b7290398eac802a614c250f7501b +README.zh.md: df58e838628df98e0c95f6f67aab76299002b3c8 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 138be128c2..4f8462b295 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,7 +31,7 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. +File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index b81f62f9db..df58e83862 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 93a3a34657..9c4fa97242 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -71,7 +71,8 @@ function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: } /** - * Render identical split buttons for files and directories. File reveal always stays last and becomes the default when no app is selected. + * Render identical split buttons for files and directories. File reveal always + * stays last; it is the default only when no application is registered. * @param props - target applications, default selection, and operations. * @returns the control and its transient failure feedback. */ @@ -79,7 +80,13 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const { applications, defaultId, kind, t } = props const [menuOpen, setMenuOpen] = useState(false) const { pending, toast, act } = useOpenTargetGesture(props.execute, t) - const preferred = applications.find(app => app.id === defaultId) + // Files open through an application whenever one is registered: the OS-marked + // default when the Host reported one, else the first entry in the Shell's + // preference order. Reveal is only promoted with no registered application, so + // a missing default marker never hides the applications behind it. + const preferred = kind === 'file' + ? applications.find(app => app.id === defaultId) ?? applications[0] + : applications.find(app => app.id === defaultId) const disabled = pending || props.busy === true || props.loading === true const hasMenu = props.loading === true || props.failed || applications.length + (kind === 'file' ? 1 : 0) > 1 const revealDefault = kind === 'file' && preferred === undefined && props.loading !== true @@ -105,7 +112,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { ...applications.map(app => ({ id: `app:${app.id}`, icon: , - label: app.id === defaultId ? t('path.appDefault', { app: app.name }) : app.name, + label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, })), ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), ]} diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 0a4a13f090..a504c3b158 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -196,16 +196,22 @@ it('keeps reveal last and makes it the default when the file query fails', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) -it.each([{ apps: [] }, { apps: [{ id: '/Player.app', name: 'Player', default: false, icon: null }] }])('uses reveal as the default after the available applications when none is default', async ({ apps }) => { +it('opens with the first registered application when none is marked default', async () => { const b = bench() - render( apps} />) + render( [{ id: '/Player.app', name: 'Player', default: false, icon: null }]} />) await act(async () => {}) - if (apps.length > 0) { - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) - expect(screen.getAllByRole('menuitem').at(-1)?.textContent).toBe('显示文件位置(默认)') - expect(screen.queryByText('用默认应用打开')).toBeNull() - fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) - } else expect(screen.queryByRole('button', { name: zh['path.more'] })).toBeNull() + const main = screen.getByRole('button', { name: t('open.title', { app: 'Player' }) }) + await act(async () => { fireEvent.click(main) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Player(默认)', zh['path.reveal']]) +}) + +it('uses reveal as the default only when no application is registered', async () => { + const b = bench() + render( []} />) + await act(async () => {}) + expect(screen.queryByRole('button', { name: zh['path.more'] })).toBeNull() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) @@ -262,7 +268,7 @@ it('uses the same application menu in the prominent empty-state control', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Music.app') }) -it('labels the prominent default action as reveal when no default application is discovered', async () => { +it('labels the prominent default action as reveal when no application is registered', async () => { const b = bench() render( []} />) await act(async () => {}) From 2545414c02216a843406979bcc5b4abb8c8ce58a Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:46:45 +0800 Subject: [PATCH 019/253] test(preview): lock ini admission to the code renderer The issue reported .ini files with no preview. On master the ini grammar is already in CODE_HIGHLIGHT_EXTENSIONS, so the code renderer claims the suffix and .ini previews. No production change is required; this test records the admission behavior so a future allowlist change cannot silently drop the reported suffix. --- .../tests/code-registration.client.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx index 17fe414745..c429935443 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx @@ -48,6 +48,15 @@ describe('code renderer registration', () => { expect(h.previews.candidates('notes.unknown')).toEqual([]) }) + it('admits ini and other text configuration suffixes to the code renderer', async () => { + const h = await boot() + await h.rt.mount(plugin) + for (const path of ['config.ini', 'C:\\work\\settings.INI', 'pyproject.toml']) { + expect(h.previews.candidates(path).map(item => item.id), path).toEqual([ID]) + } + expect(h.previews.candidates('notes.unknown')).toEqual([]) + }) + it('resolves localized names at read time and removes metadata, locale, and body on disposal', async () => { const h = await boot() await h.declare() From 6945da14cb0187d7409048eaff5da7110843b9c5 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 17:33:00 +0800 Subject: [PATCH 020/253] feat(web): unify the code-highlight language table and extend it The document Code preview and the Host read card each owned an extension-to-language table that had drifted: the preview table had 13 extensions the read card lacked, and the two returned different language ids for the same suffix. Both now resolve through the new zero-dependency @deepseek-ai/dsh-util-code-language, which also adds common Windows, config, markup, and language extensions. Every added grammar stays in the lazy set; the boot grammars are unchanged. Fixes #4822 --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/package.json | 1 + .../ui-primitives/src/code-highlighting.ts | 49 +------ .../ui-primitives/src/markdown/highlight.ts | 122 ++++++++++++++++-- .../tests/code-block.client.spec.tsx | 16 ++- .../tests/code-highlighting.client.spec.tsx | 38 ++++++ packages/client/ui-primitives/tsconfig.json | 3 + packages/fs/tool-fs/package.json | 3 +- packages/fs/tool-fs/src/read-render.ts | 42 +----- packages/fs/tool-fs/tests/read-render.spec.ts | 23 +++- packages/fs/tool-fs/tests/tools.spec.ts | 4 +- packages/fs/tool-fs/tsconfig.json | 3 + packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/code-language/README.i18n.yaml | 6 + packages/util/code-language/README.md | 50 +++++++ packages/util/code-language/README.zh.md | 50 +++++++ packages/util/code-language/package.json | 35 +++++ packages/util/code-language/src/index.ts | 114 ++++++++++++++++ .../code-language/tests/code-language.spec.ts | 95 ++++++++++++++ packages/util/code-language/tsconfig.json | 10 ++ pnpm-lock.yaml | 12 ++ scripts/doc-standard.spec.ts | 1 + scripts/package-dependency-policy.ts | 1 + scripts/verify-package-dependencies.spec.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 37 files changed, 593 insertions(+), 117 deletions(-) create mode 100644 packages/util/code-language/README.i18n.yaml create mode 100644 packages/util/code-language/README.md create mode 100644 packages/util/code-language/README.zh.md create mode 100644 packages/util/code-language/package.json create mode 100644 packages/util/code-language/src/index.ts create mode 100644 packages/util/code-language/tests/code-language.spec.ts create mode 100644 packages/util/code-language/tsconfig.json diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 805da9758c..720faa3136 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 32faec64e3ece1053430219c6be57a7044308f5a -config-catalog.zh.md: 08a7b368e0332b917de1f627a22415ede4d18f36 +config-catalog.md: 722d4afebf216915cd70f838c2e45e3739722f2a +config-catalog.zh.md: 1d7e2b3ac62c9a3b113b80bb4490921dfaf473de diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 32faec64e3..722d4afebf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4133,6 +4133,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-util-code-language` ([`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts)) - `@deepseek-ai/dsh-util-crypto` ([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) - `@deepseek-ai/dsh-util-time` ([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-values` ([`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 08a7b368e0..1d7e2b3ac6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4136,6 +4136,7 @@ export interface Config { - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-util-code-language`([`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts)) - `@deepseek-ai/dsh-util-crypto`([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) - `@deepseek-ai/dsh-util-time`([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-values`([`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1cea8df504..47cb21499b 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2e22c198c29bfba5eaa1a548af3bb7a9f190d0c8 -module-graph.zh.md: ce5c445c01b197c260d41af1d991b10fe799873a +module-graph.md: fddd44f812c4b7b9a6483765788dd8e2839dad35 +module-graph.zh.md: 23cf2de8c34c8a6d68c45e474be0ab3760677290 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e22c198c2..fddd44f812 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -20,6 +20,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] pkg_timeout["timeout"] + pkg_util_code_language["util-code-language"] pkg_util_crypto["util-crypto"] pkg_util_time["util-time"] pkg_util_values["util-values"] @@ -1348,6 +1349,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | +| [`util-code-language`](../packages/util/code-language) | `util` | — | | [`util-crypto`](../packages/util/crypto) | `util` | — | | [`util-time`](../packages/util/time) | `util` | — | | [`util-values`](../packages/util/values) | `util` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ce5c445c01..23cf2de8c3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -22,6 +22,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] pkg_timeout["timeout"] + pkg_util_code_language["util-code-language"] pkg_util_crypto["util-crypto"] pkg_util_time["util-time"] pkg_util_values["util-values"] @@ -1350,6 +1351,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | +| [`util-code-language`](../packages/util/code-language) | `util` | — | | [`util-crypto`](../packages/util/crypto) | `util` | — | | [`util-time`](../packages/util/time) | `util` | — | | [`util-values`](../packages/util/values) | `util` | — | diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index be151e0ba5..b2db491525 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7bba90f3e263c3e073c5478feb3d7dd4777d04a7 -README.zh.md: ceee6cb649672e5e47dc8325d1f277fa75953823 +README.md: 8a888bbd07c300e43383f20e223437df28154684 +README.zh.md: 3b34712eaa77e1fde30071c2379255df4d1c8647 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7bba90f3e2..8a888bbd07 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -63,7 +63,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | -| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. | +| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card. | Four pairs are easy to confuse: diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index ceee6cb649..3b34712eaa 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -63,7 +63,7 @@ kind: "package-library" | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | -| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 | +| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片背后的同一张扩展名表。 | 有四组容易混淆: diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index bb7b2de32e..5187e57a1a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -27,6 +27,7 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-util-code-language": "workspace:^", "react": "^18.2.0", "clsx": "^2.0.0", "diff": "^9.0.0", diff --git a/packages/client/ui-primitives/src/code-highlighting.ts b/packages/client/ui-primitives/src/code-highlighting.ts index 6cf4489d9a..415fe24564 100644 --- a/packages/client/ui-primitives/src/code-highlighting.ts +++ b/packages/client/ui-primitives/src/code-highlighting.ts @@ -4,51 +4,10 @@ import { grammarLoadCount, highlightLines, subscribeGrammarLoaded, type HighlightSpan, } from './markdown/highlight.ts' -const LANGUAGE_EXTENSIONS: Readonly> = { - typescript: ['ts', 'tsx', 'mts', 'cts'], - javascript: ['js', 'jsx', 'mjs', 'cjs'], - shellscript: ['sh', 'bash', 'zsh'], - json: ['json', 'jsonc', 'jsonl', 'ndjson'], - python: ['py', 'pyw', 'pyi'], - ruby: ['rb', 'rake', 'gemspec'], - go: ['go'], - rust: ['rs'], - java: ['java'], - c: ['c', 'h'], - cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'], - csharp: ['cs'], - kotlin: ['kt', 'kts'], - swift: ['swift'], - php: ['php'], - yaml: ['yaml', 'yml'], - toml: ['toml'], - ini: ['ini'], - markdown: ['md', 'markdown'], - mdx: ['mdx'], - html: ['html', 'htm', 'xhtml'], - css: ['css'], - scss: ['scss'], - less: ['less'], - sql: ['sql'], - xml: ['xml', 'xsd', 'xsl', 'xslt'], - lua: ['lua'], -} - -const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) - .flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const))) - -/** Recognized filename suffixes whose source can use the shared syntax highlighter. */ -export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] - -/** - * Select the shared syntax highlighter's grammar from a filename. - * @param path - decoded source filename or path. - * @returns a supported grammar hint, or `undefined` for other suffixes. - */ -export function languageForPath(path: string): string | undefined { - const extension = /\.([^./]+)$/u.exec(path.replaceAll('\\', '/'))?.[1]?.toLowerCase() - return extension === undefined ? undefined : LANGUAGES.get(extension) -} +// The single extension table is owned by `@deepseek-ai/dsh-util-code-language`, +// shared with the Host read card; re-export it so Client callers keep importing +// the language selector and the preview registry's suffix list from this package. +export { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' /** Highlight one source fragment into one token list per line. */ export type CodeHighlighter = (code: string) => HighlightSpan[][] | undefined diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 889ccf9325..989d22a711 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -6,13 +6,12 @@ * dark blocks), never here — the repo's tokens-only styling rule. * * Only the three markdown-fence and `run_code` grammars (TypeScript, shell, - * JSON) load into the singleton at boot — the set every session renders. The - * read card's wider extension set (the file-extension language hints the read - * tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml, - * markup, …) is imported lazily and registered the first time such a language - * is requested, so a session that never opens a read card in one of those - * languages pays neither the ~1.6 MB of grammar modules nor their synchronous - * init. The first render of a lazy language falls back to plain text while its + * JSON) load into the singleton at boot — the set every session renders. Every + * other language in the shared extension table + * (`@deepseek-ai/dsh-util-code-language`: python, rust, yaml, markup, …) is + * imported lazily and registered the first time such a language is requested, + * so a session that never opens a code surface in one of those languages pays + * neither the grammar modules nor their synchronous init. The first render of a lazy language falls back to plain text while its * grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render * with highlighting. An unknown or absent language falls back to plain text (no * highlighting, still monospace) — never an error. @@ -42,8 +41,8 @@ type LangModule = { default: typeof langTs } const LANGS = [langTs, langBash, langJson] /** - * The read card's extension grammars, each behind a dynamic import so its - * module stays out of the boot chunk until a read of that language renders. + * The non-boot extension grammars, each behind a dynamic import so its module + * stays out of the boot chunk until a code surface renders that language. * Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to. * `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader * hands the whole array to `loadLanguageSync`, which registers each entry @@ -74,6 +73,40 @@ const LAZY_GRAMMARS = new Map Promise>([ ['sql', () => import('@shikijs/langs/sql')], ['xml', () => import('@shikijs/langs/xml')], ['lua', () => import('@shikijs/langs/lua')], + ['bat', () => import('@shikijs/langs/bat')], + ['powershell', () => import('@shikijs/langs/powershell')], + ['fish', () => import('@shikijs/langs/fish')], + ['dotenv', () => import('@shikijs/langs/dotenv')], + ['log', () => import('@shikijs/langs/log')], + ['csv', () => import('@shikijs/langs/csv')], + ['diff', () => import('@shikijs/langs/diff')], + ['http', () => import('@shikijs/langs/http')], + ['rst', () => import('@shikijs/langs/rst')], + ['latex', () => import('@shikijs/langs/latex')], + ['bibtex', () => import('@shikijs/langs/bibtex')], + ['asciidoc', () => import('@shikijs/langs/asciidoc')], + ['r', () => import('@shikijs/langs/r')], + ['julia', () => import('@shikijs/langs/julia')], + ['dart', () => import('@shikijs/langs/dart')], + ['scala', () => import('@shikijs/langs/scala')], + ['clojure', () => import('@shikijs/langs/clojure')], + ['erlang', () => import('@shikijs/langs/erlang')], + ['elixir', () => import('@shikijs/langs/elixir')], + ['haskell', () => import('@shikijs/langs/haskell')], + ['fsharp', () => import('@shikijs/langs/fsharp')], + ['vb', () => import('@shikijs/langs/vb')], + ['perl', () => import('@shikijs/langs/perl')], + ['verilog', () => import('@shikijs/langs/verilog')], + ['system-verilog', () => import('@shikijs/langs/system-verilog')], + ['graphql', () => import('@shikijs/langs/graphql')], + ['proto', () => import('@shikijs/langs/proto')], + ['hcl', () => import('@shikijs/langs/hcl')], + ['nix', () => import('@shikijs/langs/nix')], + ['vue', () => import('@shikijs/langs/vue')], + ['svelte', () => import('@shikijs/langs/svelte')], + ['make', () => import('@shikijs/langs/make')], + ['cmake', () => import('@shikijs/langs/cmake')], + ['groovy', () => import('@shikijs/langs/groovy')], ]) /** @@ -81,9 +114,9 @@ const LAZY_GRAMMARS = new Map Promise>([ * plain. A Map, not an object: fence info strings are assistant-authored, so * a label like `constructor` or `__proto__` must miss instead of resolving an * inherited property and crashing the renderer inside shiki. Keys cover both - * the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids - * the read tool's `langFromPath` emits, so both callers resolve the same - * grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for + * the markdown-fence aliases `CodeBlock` uses and the file-extension language + * ids `@deepseek-ai/dsh-util-code-language` resolves, so both callers resolve the + * same grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for * the JSX/TSX approximation). A value not in {@link LANGS} names a * {@link LAZY_GRAMMARS} entry loaded on first use. */ @@ -130,6 +163,71 @@ const LANG_ALIASES = new Map([ ['sql', 'sql'], ['xml', 'xml'], ['lua', 'lua'], + ['bat', 'bat'], + ['batch', 'bat'], + ['powershell', 'powershell'], + ['ps1', 'powershell'], + ['ps', 'powershell'], + ['fish', 'fish'], + ['properties', 'ini'], + ['dotenv', 'dotenv'], + ['env', 'dotenv'], + ['log', 'log'], + ['csv', 'csv'], + ['diff', 'diff'], + ['patch', 'diff'], + ['http', 'http'], + ['rst', 'rst'], + ['latex', 'latex'], + ['tex', 'latex'], + ['bibtex', 'bibtex'], + ['bib', 'bibtex'], + ['asciidoc', 'asciidoc'], + ['adoc', 'asciidoc'], + ['r', 'r'], + ['julia', 'julia'], + ['jl', 'julia'], + ['dart', 'dart'], + ['scala', 'scala'], + ['clojure', 'clojure'], + ['clj', 'clojure'], + ['erlang', 'erlang'], + ['erl', 'erlang'], + ['elixir', 'elixir'], + ['ex', 'elixir'], + ['exs', 'elixir'], + ['haskell', 'haskell'], + ['hs', 'haskell'], + ['fsharp', 'fsharp'], + ['fs', 'fsharp'], + ['fsi', 'fsharp'], + ['fsx', 'fsharp'], + ['vb', 'vb'], + ['vbnet', 'vb'], + ['perl', 'perl'], + ['pl', 'perl'], + ['pm', 'perl'], + ['verilog', 'verilog'], + ['system-verilog', 'system-verilog'], + ['systemverilog', 'system-verilog'], + ['sv', 'system-verilog'], + ['svh', 'system-verilog'], + ['graphql', 'graphql'], + ['gql', 'graphql'], + ['proto', 'proto'], + ['protobuf', 'proto'], + ['hcl', 'hcl'], + ['tf', 'hcl'], + ['tfvars', 'hcl'], + ['nix', 'nix'], + ['vue', 'vue'], + ['svelte', 'svelte'], + ['make', 'make'], + ['makefile', 'make'], + ['mk', 'make'], + ['cmake', 'cmake'], + ['groovy', 'groovy'], + ['gradle', 'groovy'], ]) /** diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index 5e6475297c..9ea456df4d 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -33,16 +33,21 @@ describe('highlightToHtml', () => { expect(highlightToHtml('x', undefined)).toBeUndefined() }) - // Every read-tool language hint whose grammar loads lazily (the boot set — - // ts/js/shell/sh/json — is covered above). Touching each one drives its own - // dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised. + // Every language whose grammar loads lazily (the boot set — ts/js/shell/sh/json + // — is covered above). Touching each one drives its own dynamic import thunk, so + // the whole LAZY_GRAMMARS table is exercised. const LAZY_ALIASES = [ 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php', 'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql', 'xml', 'lua', + 'fish', 'dotenv', 'log', 'csv', 'diff', 'http', 'rst', 'latex', 'bibtex', + 'asciidoc', 'bat', 'powershell', 'r', 'julia', 'dart', 'scala', 'clojure', + 'erlang', 'elixir', 'haskell', 'fsharp', 'vb', 'perl', 'verilog', + 'system-verilog', 'graphql', 'proto', 'hcl', 'nix', 'vue', 'svelte', 'make', + 'cmake', 'groovy', ] - it('lazily loads every read-card grammar: plain first, highlighted after load', async () => { + it('lazily loads every extension grammar: plain first, highlighted after load', async () => { const registered = Promise.withResolvers() // Registration notifications, not a private polling deadline, establish readiness. const stop = subscribeGrammarLoaded(() => { @@ -55,7 +60,8 @@ describe('highlightToHtml', () => { } finally { stop() } - }) + // 57 dynamic grammars (some with large embedded sub-grammars) exceed the default. + }, 120_000) }) describe('CodeBlock', () => { diff --git a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx index 04a54eaceb..c8fdde9199 100644 --- a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { languageForPath as sharedLanguageForPath } from '@deepseek-ai/dsh-util-code-language' import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts' import { supportsHighlighting } from '../src/markdown/highlight.ts' @@ -24,6 +25,32 @@ describe('code highlighting', () => { expect(languageForPath(path)).toBe(language) }) + it.each([ + ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], + ['module.psm1', 'powershell'], ['config.fish', 'fish'], ['app.properties', 'ini'], + ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], + ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], ['doc.cls', 'latex'], + ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], + ['analysis.r', 'r'], ['model.jl', 'julia'], ['main.dart', 'dart'], ['Main.scala', 'scala'], + ['core.clj', 'clojure'], ['ui.cljs', 'clojure'], ['data.edn', 'clojure'], ['app.erl', 'erlang'], + ['app.hrl', 'erlang'], ['app.ex', 'elixir'], ['app.exs', 'elixir'], ['Main.hs', 'haskell'], + ['Types.fs', 'fsharp'], ['Types.fsi', 'fsharp'], ['Form.vb', 'vb'], ['script.pl', 'perl'], + ['Module.pm', 'perl'], ['top.v', 'verilog'], ['top.sv', 'system-verilog'], + ['defs.svh', 'system-verilog'], ['schema.graphql', 'graphql'], ['query.gql', 'graphql'], + ['message.proto', 'proto'], ['main.tf', 'hcl'], ['terraform.tfvars', 'hcl'], ['stack.hcl', 'hcl'], + ['flake.nix', 'nix'], ['App.vue', 'vue'], ['App.svelte', 'svelte'], ['build.mk', 'make'], + ['CMakeLists.cmake', 'cmake'], ['build.gradle', 'groovy'], + ])('uses the extended %s grammar hint %s', (path, language) => { + expect(languageForPath(path)).toBe(language) + }) + + it('resolves through the one shared extension table, never a local copy', () => { + // A second table re-introduced here fails this identity assertion before it + // can drift from the read card's file-extension hints. + expect(languageForPath).toBe(sharedLanguageForPath) + }) + it('keeps every registered suffix highlightable by the shared primitive', () => { expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { @@ -50,4 +77,15 @@ describe('code highlighting', () => { await waitFor(() => { expect(lazy.result.current).not.toBe(loading) }) expect(lazy.result.current('local answer = 42')).not.toBeUndefined() }) + + it.each(['powershell', 'system-verilog', 'bat'])('loads the %s grammar through the lazy path', async (language) => { + const hook = renderHook(() => useCodeHighlighter(language)) + const initial = hook.result.current + // Invoking the plain fallback starts the dynamic import; the callback + // identity changes once the grammar registers and notifies subscribers. + if (initial('sample') === undefined) { + await waitFor(() => { expect(hook.result.current).not.toBe(initial) }) + } + expect(hook.result.current('sample')).not.toBeUndefined() + }) }) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index 201a8a711e..edee79e624 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -13,6 +13,9 @@ "references": [ { "path": "../store" + }, + { + "path": "../../util/code-language" } ] } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 4f0e9a11a6..7d6e8e5d79 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -28,7 +28,8 @@ "license": "MIT", "dependencies": { "diff": "^9.0.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-util-code-language": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 19b6c0b1e7..7862a4366f 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -170,44 +170,14 @@ ${body} } /** - * Lowercased file-extension to syntax-highlighting language hint. Keys are the - * extension without its dot; a UI treats an absent key as plain text. The map is - * intentionally small — common source, config, and markup extensions a - * line-numbered code view benefits from highlighting — not an exhaustive registry. - */ -const LANG_BY_EXTENSION: Readonly> = { - ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', - js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', - json: 'json', jsonc: 'json', - py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', - c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', - cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', - sh: 'sh', bash: 'sh', zsh: 'sh', - yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', - md: 'md', markdown: 'md', mdx: 'mdx', - html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', - sql: 'sql', xml: 'xml', lua: 'lua', -} - -/** - * Derive a syntax-highlighting language hint from a read path's file extension. - * Pure and case-insensitive on the extension; a dotfile with no extension - * (`.gitignore`) and an unknown extension both yield `undefined`. + * Derive a syntax-highlighting language id from a read path's file extension via + * the extension table shared with the Client code surfaces; see + * `@deepseek-ai/dsh-util-code-language` for the table and the path rules (both + * separators, a leading dot as the extension separator, and prototype-key safety). * @param path - the model-facing path the read reported. - * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none. + * @returns the language id, or `undefined` when the extension maps to none. */ -export function langFromPath(path: string): string | undefined { - const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = base.lastIndexOf('.') - // A leading dot is a dotfile (no extension), not an empty extension. - if (dot <= 0) return undefined - const ext = base.slice(dot + 1).toLowerCase() - // Own-property check only: a filename whose extension is an Object.prototype - // key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the - // inherited member — otherwise a function would reach `lang` and fail the - // tool-output JSON validation. - return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined -} +export { languageForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' /** * The `read` tool's private `tool/result` `meta` payload: the structured diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index cc03a0c8f9..2e364b8e74 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,6 +6,7 @@ */ import { describe, expect, it } from 'vitest' +import { languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -118,19 +119,29 @@ describe('buildWindow', () => { }) describe('langFromPath', () => { - it('maps a known extension to its language hint, case-insensitively', () => { - expect(langFromPath('src/a.ts')).toBe('ts') - expect(langFromPath('src/a.TSX')).toBe('tsx') - expect(langFromPath('/abs/module.mjs')).toBe('js') + it('resolves through the one shared extension table, never a local copy', () => { + // The read card and the Client code surfaces must agree on every path; a + // local table re-introduced on either side makes one of these identity + // assertions fail before the two can drift in language hints. + expect(langFromPath).toBe(languageForPath) + for (const path of ['src/a.ts', 'conf.yml', 'README.md', 'build.PS1', 'C:\\src\\main.rs']) { + expect(langFromPath(path), path).toBe(languageForPath(path)) + } + }) + + it('maps a known extension to its canonical language id, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('typescript') + expect(langFromPath('src/a.TSX')).toBe('typescript') + expect(langFromPath('/abs/module.mjs')).toBe('javascript') expect(langFromPath('conf.yml')).toBe('yaml') - expect(langFromPath('README.md')).toBe('md') + expect(langFromPath('README.md')).toBe('markdown') }) it('reads the extension after the last path segment and last dot', () => { expect(langFromPath('a.py.bak')).toBeUndefined() expect(langFromPath('archive.tar.gz')).toBeUndefined() expect(langFromPath('/dir.py/plain')).toBeUndefined() - expect(langFromPath('C:\\src\\main.rs')).toBe('rs') + expect(langFromPath('C:\\src\\main.rs')).toBe('rust') }) it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 28613a2920..ac70df7015 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -352,7 +352,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'ts', + lang: 'typescript', }) const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) expect(view).toEqual({ @@ -361,7 +361,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'ts', + lang: 'typescript', content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], }) }) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 5ae5f31375..ca6e6e073a 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -41,6 +41,9 @@ }, { "path": "../../attachment/attachment" + }, + { + "path": "../../util/code-language" } ] } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 2499f0a247..7e5618085f 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md -README.md: a51f8304e621e1c4fba79e5492c8eddc4d786a8e -README.zh.md: d2b8734543a7332b8a18fd9202c6fcf3b9736af9 +README.md: bd89353f66f8e852f531b393135aff76bbd612ca +README.zh.md: 52807d86206317a31fe5196825bf8a0ac5402d62 diff --git a/packages/util/README.md b/packages/util/README.md index a51f8304e6..bd89353f66 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -39,6 +39,7 @@ Each package provides one primitive; open a package page for how to use it. | [`lazy-require/`](lazy-require/README.md) | Loads CommonJS-compatible Host dependencies on first use with caller-relative resolution | | [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string | | [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | +| [`code-language/`](code-language/README.md) | Maps a filename suffix to the syntax-highlighting language Code preview, diff review, and the read card render | | [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata | | [`time/`](time/README.md) | Validates and canonicalizes a caller-reported IANA time zone | | [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index d2b8734543..52807d8620 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -39,6 +39,7 @@ kind: "package-group" | [`lazy-require/`](lazy-require/README.zh.md) | 在首次使用时按调用方位置加载兼容 CommonJS 的 Host 依赖 | | [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 | | [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 | +| [`code-language/`](code-language/README.zh.md) | 把文件名后缀映射为 Code 预览、diff 审阅与 read 卡片渲染的语法高亮语言 | | [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 | | [`time/`](time/README.zh.md) | 校验并规范化调用方所报的 IANA 时区 | | [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时和取消分类 | diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml new file mode 100644 index 0000000000..e9789fe208 --- /dev/null +++ b/packages/util/code-language/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/util/code-language/README.md +README.md: a65c28cc11e5aa112b5450b9ec42b23dce764c9d +README.zh.md: cc15751853a8ab8b796bf16f989ee001d7347d9e diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md new file mode 100644 index 0000000000..a65c28cc11 --- /dev/null +++ b/packages/util/code-language/README.md @@ -0,0 +1,50 @@ +--- +description: "Single file-extension to syntax-highlighting language table shared by the Client code surfaces and the Host read card." +kind: "package-library" +--- + +# dsh-util-code-language + +English | [中文](README.zh.md) + +## Summary + +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a filename or path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the extension separator (`.env` resolves to `dotenv`). `CODE_HIGHLIGHT_EXTENSIONS` lists every recognized suffix for a preview registry. The package is browser-safe, has no Cordis service or runtime state, and leaves the actual tokenization to the Client highlighter. + +## Table of Contents + +- [Language selection](#language-selection) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Language selection + +The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. + +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. + +----- + +## Known Limitations and Deferred Work + + + +- **Extension-only matching** — `languageForPath` reads a suffix, so names recognized without one (`Dockerfile`, `Makefile`, `.gitignore`, `.editorconfig`) stay unlisted. Filename rules are deferred. +- **No content sniffing** — a file with an absent or unknown extension stays plain text even when its bytes are unambiguous; the table never inspects content. +- **Curated, not exhaustive** — the table is smaller than Shiki's grammar catalog and GitHub linguist; adding a language means adding both the extension entry and the Client grammar registration. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. This utility owns no mutable runtime relationship. diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md new file mode 100644 index 0000000000..cc15751853 --- /dev/null +++ b/packages/util/code-language/README.zh.md @@ -0,0 +1,50 @@ +--- +description: "Client 代码界面与 Host read 卡片共享的唯一「扩展名 → 语法高亮语言」表。" +kind: "package-library" +--- + +# dsh-util-code-language + +[English](README.md) | 中文 + +## 概述 + +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 + +## 目录 + +- [语言选择](#language-selection) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 语言选择 + +表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 + +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 + +----- + +## 已知限制与暂缓事项 + + + +- **只匹配扩展名**——`languageForPath` 只看后缀,因此 `Dockerfile`、`Makefile`、`.gitignore`、`.editorconfig` 这类靠文件名识别的名称仍不表。文件名规则暂缓。 +- **不做内容嗅探**——扩展名缺失或未知时,即使字节含义明确也按纯文本处理;本表从不读取内容。 +- **精选而非穷举**——本表小于 Shiki 的 grammar 目录与 GitHub linguist;新增语言意味着同时添加扩展名条目与 Client grammar 注册。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变式:** 不发布伴生入口。这个工具不持有可变运行时关系。 diff --git a/packages/util/code-language/package.json b/packages/util/code-language/package.json new file mode 100644 index 0000000000..640a5eb7fa --- /dev/null +++ b/packages/util/code-language/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-util-code-language", + "description": "Single file-extension to syntax-highlighting language table shared by Client code surfaces and the Host read card", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/code-language" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts new file mode 100644 index 0000000000..09aac2143f --- /dev/null +++ b/packages/util/code-language/src/index.ts @@ -0,0 +1,114 @@ +/** + * The single file-extension to syntax-highlighting language table shared by every + * code surface: the Client's document Code preview and diff review, and the Host + * read tool's persisted `lang` hint. Language ids are the grammar ids the Client + * highlighter's alias table resolves, so a language returned here reaches + * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or + * one naming a language the highlighter does not register, renders as plain text. + * @module @deepseek-ai/dsh-util-code-language + */ + +/** + * Recognized extensions per canonical language id. Values are lowercase + * extensions without the dot; the id names the grammar rather than a display + * label. The set covers common source, config, script, data, and markup + * extensions a line-numbered code view or preview benefits from highlighting; it + * is deliberately not an exhaustive linguist registry. Extensions whose Shiki + * grammar does not exist map to the nearest available grammar + * (`properties` to `ini`, whose registration carries the `properties` alias) or + * stay unlisted. Certificate and lock extensions (`pem`, `crt`, `key`, `cer`, + * `lock`) intentionally stay unlisted. + */ +const LANGUAGE_EXTENSIONS: Readonly> = { + typescript: ['ts', 'tsx', 'mts', 'cts'], + javascript: ['js', 'jsx', 'mjs', 'cjs'], + shellscript: ['sh', 'bash', 'zsh'], + fish: ['fish'], + json: ['json', 'jsonc', 'jsonl', 'ndjson', 'ipynb'], + python: ['py', 'pyw', 'pyi'], + ruby: ['rb', 'rake', 'gemspec'], + go: ['go'], + rust: ['rs'], + java: ['java'], + c: ['c', 'h'], + cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'], + csharp: ['cs'], + kotlin: ['kt', 'kts'], + swift: ['swift'], + php: ['php'], + yaml: ['yaml', 'yml'], + toml: ['toml'], + ini: ['ini', 'conf', 'cfg', 'properties'], + dotenv: ['env'], + log: ['log'], + csv: ['csv'], + diff: ['diff', 'patch'], + http: ['http'], + markdown: ['md', 'markdown'], + mdx: ['mdx'], + rst: ['rst'], + latex: ['tex', 'sty', 'cls'], + bibtex: ['bib'], + asciidoc: ['adoc'], + html: ['html', 'htm', 'xhtml'], + css: ['css'], + scss: ['scss'], + less: ['less'], + sql: ['sql'], + xml: ['xml', 'xsd', 'xsl', 'xslt', 'plist', 'svg'], + lua: ['lua'], + bat: ['bat', 'cmd'], + powershell: ['ps1', 'psm1', 'psd1'], + r: ['r'], + julia: ['jl'], + dart: ['dart'], + scala: ['scala'], + clojure: ['clj', 'cljs', 'edn'], + erlang: ['erl', 'hrl'], + elixir: ['ex', 'exs'], + haskell: ['hs'], + fsharp: ['fs', 'fsi', 'fsx'], + vb: ['vb'], + perl: ['pl', 'pm'], + verilog: ['v'], + 'system-verilog': ['sv', 'svh'], + graphql: ['graphql', 'gql'], + proto: ['proto'], + hcl: ['tf', 'tfvars', 'hcl'], + nix: ['nix'], + vue: ['vue'], + svelte: ['svelte'], + make: ['makefile', 'mk'], + cmake: ['cmake'], + groovy: ['gradle'], +} + +/** + * Lowercase extension to canonical language id. A Map, not an object: a filename + * whose extension is an `Object.prototype` key (`foo.constructor`, + * `foo.__proto__`) must miss instead of resolving the inherited member, which + * would otherwise reach callers as a non-string language value. + */ +const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) + .flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const))) + +/** Every recognized filename suffix, one entry each; a document-preview registry uses it to claim Code-rendered bodies. */ +export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] + +/** + * Derive the syntax-highlighting language from a filename or path, case + * insensitively. The extension is the text after the last dot of the final path + * segment; a leading dot is still the separator, so `.env` resolves to `dotenv` + * while an unlisted dotfile (`.gitignore`) and an extensionless name return + * `undefined`. Both path separators are recognized, so + * `C:\\dir\\main.PS1` resolves like `dir/main.ps1`. + * @param path - decoded filename or path. + * @returns the canonical language id, or `undefined` for an unrecognized or absent suffix. + */ +export function languageForPath(path: string): string | undefined { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + const base = path.slice(slash + 1) + const dot = base.lastIndexOf('.') + if (dot < 0) return undefined + return LANGUAGES.get(base.slice(dot + 1).toLowerCase()) +} diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts new file mode 100644 index 0000000000..0ede43e840 --- /dev/null +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '../src/index.ts' + +describe('languageForPath', () => { + it.each([ + // Windows-facing script and shell extensions. + ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], + ['module.psm1', 'powershell'], ['data.psd1', 'powershell'], ['config.fish', 'fish'], + // Config, data, and text extensions. + ['app.properties', 'ini'], ['app.conf', 'ini'], ['app.cfg', 'ini'], + ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], + ['notebook.ipynb', 'json'], + // Documentation and markup extensions. + ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], + ['doc.cls', 'latex'], ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], + ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], + // Language extensions. + ['analysis.r', 'r'], ['model.jl', 'julia'], ['main.dart', 'dart'], + ['Main.scala', 'scala'], ['core.clj', 'clojure'], ['ui.cljs', 'clojure'], + ['data.edn', 'clojure'], ['app.erl', 'erlang'], ['app.hrl', 'erlang'], + ['app.ex', 'elixir'], ['app.exs', 'elixir'], ['Main.hs', 'haskell'], + ['Types.fs', 'fsharp'], ['Types.fsi', 'fsharp'], ['script.fsx', 'fsharp'], + ['Form.vb', 'vb'], ['script.pl', 'perl'], ['Module.pm', 'perl'], + ['top.v', 'verilog'], ['top.sv', 'system-verilog'], ['defs.svh', 'system-verilog'], + ['schema.graphql', 'graphql'], ['query.gql', 'graphql'], ['message.proto', 'proto'], + ['main.tf', 'hcl'], ['terraform.tfvars', 'hcl'], ['stack.hcl', 'hcl'], + ['flake.nix', 'nix'], ['App.vue', 'vue'], ['App.svelte', 'svelte'], + ['Makefile', undefined], ['build.mk', 'make'], ['CMakeLists.cmake', 'cmake'], + ['build.gradle', 'groovy'], + ])('resolves %s to %s', (path, language) => { + expect(languageForPath(path)).toBe(language) + }) + + it('keeps the extensions the two former tables already agreed on', () => { + expect(languageForPath('src/a.ts')).toBe('typescript') + expect(languageForPath('src/a.TSX')).toBe('typescript') + expect(languageForPath('/abs/module.mjs')).toBe('javascript') + expect(languageForPath('conf.yml')).toBe('yaml') + expect(languageForPath('README.md')).toBe('markdown') + expect(languageForPath('C:\\src\\main.rs')).toBe('rust') + }) + + it('resolves every extension that only the preview table knew, including the new ones', () => { + for (const extension of ['jsonl', 'ndjson', 'pyw', 'pyi', 'rake', 'gemspec', 'hh', 'hxx', 'kts', 'xhtml', 'xsd', 'xsl', 'xslt']) { + expect(languageForPath(`file.${extension}`), extension).toBeDefined() + } + }) + + it('is case-insensitive and accepts Windows separators', () => { + expect(languageForPath('C:\\path\\X.PS1')).toBe('powershell') + expect(languageForPath('C:\\Proj\\Build.CMD')).toBe('bat') + expect(languageForPath('dir\\sub\\Main.SCALA')).toBe('scala') + }) + + it('reads the suffix after the last path segment and last dot', () => { + expect(languageForPath('a.py.bak')).toBeUndefined() + expect(languageForPath('archive.tar.gz')).toBeUndefined() + expect(languageForPath('/dir.py/plain')).toBeUndefined() + expect(languageForPath('dir.ts/README')).toBeUndefined() + }) + + it('returns undefined for a dotfile, an extensionless name, a trailing dot, and an unknown suffix', () => { + expect(languageForPath('.gitignore')).toBeUndefined() + expect(languageForPath('/etc/hosts')).toBeUndefined() + expect(languageForPath('trailingdot.')).toBeUndefined() + expect(languageForPath('data.unknownext')).toBeUndefined() + }) + + it('returns undefined for a filename whose extension is an Object.prototype key', () => { + expect(languageForPath('foo.constructor')).toBeUndefined() + expect(languageForPath('foo.__proto__')).toBeUndefined() + expect(languageForPath('foo.toString')).toBeUndefined() + expect(languageForPath('foo.hasOwnProperty')).toBeUndefined() + }) + + it('leaves certificate and lock extensions unlisted', () => { + for (const extension of ['pem', 'crt', 'key', 'cer', 'lock']) { + expect(languageForPath(`secret.${extension}`), extension).toBeUndefined() + } + }) +}) + +describe('CODE_HIGHLIGHT_EXTENSIONS', () => { + it('lists every recognized suffix exactly once', () => { + expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) + expect(CODE_HIGHLIGHT_EXTENSIONS.length).toBeGreaterThan(100) + }) + + it('resolves each listed suffix back to a language', () => { + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + expect(languageForPath(`file.${extension}`), extension).toBeDefined() + } + }) +}) diff --git a/packages/util/code-language/tsconfig.json b/packages/util/code-language/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/util/code-language/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ffe4df02d..a30dfcb441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4053,6 +4053,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store + '@deepseek-ai/dsh-util-code-language': + specifier: workspace:^ + version: link:../../util/code-language '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 @@ -7806,6 +7809,9 @@ importers: packages/fs/tool-fs: dependencies: + '@deepseek-ai/dsh-util-code-language': + specifier: workspace:^ + version: link:../../util/code-language '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -12519,6 +12525,12 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis + packages/util/code-language: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + packages/util/crypto: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 6c6663f286..f22f05910b 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -91,6 +91,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/crypto': 'Zero-dependency identifier minting utility.', 'packages/util/deque': 'Zero-dependency circular deque utility.', 'packages/util/chunked-list': 'Persistent collection operations and checkpoint validation without a plugin surface.', + 'packages/util/code-language': 'Zero-dependency file-extension to syntax-highlighting language table.', 'packages/util/home-paths': 'Zero-dependency harness-home path resolver.', 'packages/util/launch-environment': 'Zero-dependency environment resolver.', 'packages/util/lazy-require': 'Caller-relative CommonJS-compatible dependency loader.', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 57903b0e03..06d86db2ff 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -34,6 +34,7 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ '@deepseek-ai/dsh-brand', '@deepseek-ai/dsh-lazy-require', '@deepseek-ai/dsh-typert-protocol', + '@deepseek-ai/dsh-util-code-language', '@deepseek-ai/dsh-util-crypto', '@deepseek-ai/dsh-util-values', ] diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 2c4c147dd7..ced2c141a3 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -206,6 +206,7 @@ describe('package dependency scope', () => { '@deepseek-ai/dsh-brand', '@deepseek-ai/dsh-lazy-require', '@deepseek-ai/dsh-typert-protocol', + '@deepseek-ai/dsh-util-code-language', '@deepseek-ai/dsh-util-crypto', '@deepseek-ai/dsh-util-values', ]) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6a4ee18a25..4864264e68 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -36,6 +36,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', 'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.', 'packages/util/values': 'The package only validates, snapshots, compares, freezes, or rejects caller-owned values; consumers own every model-facing use.', + 'packages/util/code-language': 'The package only maps a filename suffix to a syntax-highlighting language id; the read consumer owns the persisted hint.', } /** diff --git a/tsconfig.base.json b/tsconfig.base.json index f386f12aba..fbf34b6997 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -305,6 +305,7 @@ "@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], "@deepseek-ai/dsh-util-values": ["./packages/util/values/src"], + "@deepseek-ai/dsh-util-code-language": ["./packages/util/code-language/src"], // util/ folders are role-named without the util- prefix their npm names carry, // so these aliases stay hand-written like dsh-util-crypto above. "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 50c576de65..b6ff4da739 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -218,6 +218,7 @@ { "path": "./packages/util/chunked-list" }, { "path": "./packages/util/values" }, { "path": "./packages/util/workspace-path" }, + { "path": "./packages/util/code-language" }, { "path": "./packages/util/output-retention" }, { "path": "./packages/util/atomic-write" }, { "path": "./packages/attachment/attachment" }, From dc07e5a50dadcf03e3fb9e1b7c69bb2ba9550254 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 17:36:16 +0800 Subject: [PATCH 021/253] fix(tools): keep surrogate pairs intact when capping tool output Three tools capped the preview text they return with String.prototype.slice at a UTF-16 code-unit budget. A cut that lands inside a surrogate pair leaves a lone high surrogate, which is not well-formed text: a strict JSON reader rejects the durable Session log carrying it, and the preview shows a broken glyph. A lone surrogate retained in history has already blocked every later Messages request for one session. Centralize the cap in output-retention as truncateWithoutSplittingSurrogatePair, and use it from the persistent bash and pwsh shells and the string-replace editor. The cut drops the unpaired half instead of keeping it, so the retained text stays a prefix and is at most one code unit shorter than before. The new dependency edges and the shifted Config sources regenerate docs/module-graph and docs/config-catalog, including the committed translation pair. Refs #4817 --- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 6 ++-- docs/config-catalog.zh.md | 6 ++-- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 9 +++-- docs/module-graph.zh.md | 9 +++-- .../fs/tool-str-replace-editor/package.json | 2 ++ .../fs/tool-str-replace-editor/src/index.ts | 3 +- .../tests/tools.spec.ts | 15 +++++++++ .../fs/tool-str-replace-editor/tsconfig.json | 1 + .../shell/tool-bash-persistent/package.json | 2 ++ .../shell/tool-bash-persistent/src/index.ts | 3 +- .../tool-bash-persistent/tests/tools.spec.ts | 18 +++++++++- .../shell/tool-bash-persistent/tsconfig.json | 1 + .../shell/tool-pwsh-persistent/package.json | 2 ++ .../shell/tool-pwsh-persistent/src/index.ts | 3 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 18 +++++++++- .../shell/tool-pwsh-persistent/tsconfig.json | 1 + .../util/output-retention/README.i18n.yaml | 4 +-- packages/util/output-retention/README.md | 4 +-- packages/util/output-retention/README.zh.md | 4 +-- packages/util/output-retention/src/index.ts | 17 ++++++++++ .../tests/output-retention.spec.ts | 33 +++++++++++++++++++ pnpm-lock.yaml | 9 +++++ 24 files changed, 151 insertions(+), 27 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 43f977e619..bbb66436dd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c46008dd88e51dc079d72e6605d06176c26bdea1 -config-catalog.zh.md: 7376476771902e1bbcba3ae4d6374b1087e14a72 +config-catalog.md: 6aa27aec967066deb217d1a065b093ba4f09f20f +config-catalog.zh.md: 7051e686c6081c5da65745335bbda2ddef81edce diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c46008dd88..6aa27aec96 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3249,7 +3249,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -3443,7 +3443,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts) +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:481`](../packages/shell/tool-pwsh-persistent/src/index.ts) @@ -3517,7 +3517,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7376476771..7051e686c6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3251,7 +3251,7 @@ export interface Config { } ``` -来源: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts) +来源: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -3445,7 +3445,7 @@ export interface Config { } ``` -来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts) +来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:481`](../packages/shell/tool-pwsh-persistent/src/index.ts) @@ -3519,7 +3519,7 @@ export interface Config { } ``` -来源: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts) +来源: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 235cd3fdef..12cbb59624 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e36487734f39545fee9b188d9443336ad7ed2e0e -module-graph.zh.md: 646aa21ea348ddf5e73d12b94247319cfe3768d6 +module-graph.md: 44e499d26af5753a260d6bc0802ef67adb6de475 +module-graph.zh.md: 8e95ddc3a53956c92194c3274484695dcf599619 diff --git a/docs/module-graph.md b/docs/module-graph.md index e36487734f..44e499d26a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -780,6 +780,7 @@ flowchart TD pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs + pkg_tool_str_replace_editor --> pkg_output_retention pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools @@ -929,10 +930,12 @@ flowchart TD pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools pkg_tool_bash_persistent --> pkg_agent + pkg_tool_bash_persistent --> pkg_output_retention pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_output_retention pkg_tool_pwsh_persistent --> pkg_terminal pkg_tool_pwsh_persistent --> pkg_timeout pkg_tool_pwsh_persistent --> pkg_tools @@ -1540,7 +1543,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | @@ -1572,8 +1575,8 @@ flowchart TD | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | -| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 646aa21ea3..8e95ddc3a5 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -782,6 +782,7 @@ flowchart TD pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs + pkg_tool_str_replace_editor --> pkg_output_retention pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools @@ -931,10 +932,12 @@ flowchart TD pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools pkg_tool_bash_persistent --> pkg_agent + pkg_tool_bash_persistent --> pkg_output_retention pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_output_retention pkg_tool_pwsh_persistent --> pkg_terminal pkg_tool_pwsh_persistent --> pkg_timeout pkg_tool_pwsh_persistent --> pkg_tools @@ -1542,7 +1545,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | @@ -1574,8 +1577,8 @@ flowchart TD | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | -| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index da5a7bc917..4119e284f9 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -27,6 +27,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -43,6 +44,7 @@ "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index f77736685e..3542ee1770 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -33,7 +34,7 @@ Notes for using the \`str_replace\` command: function maybeTruncate(content: string, maxOutputChars: number): string { return content.length <= maxOutputChars ? content - : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE + : truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE } function codepointCompare(left: string, right: string): number { diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 1f893f37fe..2bd7633706 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -347,6 +347,21 @@ describe('tool-str-replace-editor', () => { .toContain('') }) + it('clips a view at a code-unit boundary without splitting a surrogate pair', async () => { + const wide = await setup({ maxOutputChars: 10_000 }) + const file = join(wide.root, 'emoji.txt') + await writeFile(file, `${'😀'.repeat(4)}tail`) + const untruncated = text(await call(wide.ctx, wide.owner, { command: 'view', path: file })) + // The cap lands on the high surrogate of the first emoji. + const splitAt = untruncated.indexOf('😀') + 1 + + const clipped = await setup({ maxOutputChars: splitAt }) + const rendered = text(await call(clipped.ctx, clipped.owner, { command: 'view', path: file })) + + expect(rendered.startsWith(`${untruncated.slice(0, splitAt - 1)}`)).toBe(true) + expect(rendered).not.toContain('\uD83D') + }) + it('matches canonical empty-line, range, and end-insert behavior', async () => { const { ctx, root, owner } = await setup() const empty = join(root, 'empty.txt') diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json index b9dba81d5d..686cded3e0 100644 --- a/packages/fs/tool-str-replace-editor/tsconfig.json +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/output-retention" }, { "path": "../../core/tools" }, { "path": "../fs" }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 7587e0be20..f3f65c754a 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -27,6 +27,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index c248ca94b5..ff6aa0f5be 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -58,7 +59,7 @@ function maybeTruncate(content: string, maxOutputChars: number, incomplete = fal if (content.length <= maxOutputChars && !incomplete) return content return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE - : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE + : truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE } function markers(): CommandMarkers { diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index a79d116556..cc962c4447 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -114,6 +114,7 @@ class StubPtySession implements TerminalBackendSession { pendingText = '' historyTruncated = false throwOnSend = false + largeOutput = 'x'.repeat(100) constructor(mode: StubMode) { this.mode = mode @@ -206,7 +207,7 @@ class StubPtySession implements TerminalBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } const commandOutput = this.mode === 'large' - ? 'x'.repeat(100) + ? this.largeOutput : this.mode === 'nonzero' ? '' : 'hello from stub' const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` @@ -404,6 +405,21 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('clips a split surrogate pair instead of leaving a lone half', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 10 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'large' + // The tenth code unit is the emoji's high surrogate, so the clip drops the + // unpaired half instead of emitting it. + session.largeOutput = `${'x'.repeat(9)}😀tail` + + const rendered = text(await call(ctx, owner, 'emoji')) + + expect(rendered.startsWith(`${'x'.repeat(9)}`)).toBe(true) + expect(rendered).not.toContain('\uD83D') + }) + it('waits for status digits after a torn completion marker', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') diff --git a/packages/shell/tool-bash-persistent/tsconfig.json b/packages/shell/tool-bash-persistent/tsconfig.json index fe64fdde1f..e3b72276d1 100644 --- a/packages/shell/tool-bash-persistent/tsconfig.json +++ b/packages/shell/tool-bash-persistent/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/output-retention" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, { "path": "../../terminal/terminal" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 2d14f4f879..a1147eaf54 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -27,6 +27,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 34f8cf7732..0448ddc076 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -58,7 +59,7 @@ function maybeTruncate(content: string, maxOutputChars: number, incomplete = fal if (content.length <= maxOutputChars && !incomplete) return content return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE - : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE + : truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE } function markers(): CommandMarkers { diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index c260f818eb..67825d8b17 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -119,6 +119,7 @@ class StubTerminalSession implements TerminalBackendSession { pendingText = '' historyTruncated = false throwOnSend = false + largeOutput = 'x'.repeat(100) constructor(mode: StubMode) { this.mode = mode @@ -218,7 +219,7 @@ class StubTerminalSession implements TerminalBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } const commandOutput = this.mode === 'large' - ? 'x'.repeat(100) + ? this.largeOutput : this.mode === 'nonzero' ? '' : this.mode === 'prompt-collision' ? this.motd : 'hello from stub' @@ -455,6 +456,21 @@ describe('tool-pwsh-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('clips a split surrogate pair instead of leaving a lone half', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 10 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'large' + // The tenth code unit is the emoji's high surrogate, so the clip drops the + // unpaired half instead of emitting it. + session.largeOutput = `${'x'.repeat(9)}😀tail` + + const rendered = text(await call(ctx, owner, 'emoji')) + + expect(rendered.startsWith(`${'x'.repeat(9)}`)).toBe(true) + expect(rendered).not.toContain('\uD83D') + }) + it('waits for status digits after a torn completion marker', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') diff --git a/packages/shell/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json index fe64fdde1f..e3b72276d1 100644 --- a/packages/shell/tool-pwsh-persistent/tsconfig.json +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/output-retention" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, { "path": "../../terminal/terminal" }, diff --git a/packages/util/output-retention/README.i18n.yaml b/packages/util/output-retention/README.i18n.yaml index 71736fe9bf..53c073815a 100644 --- a/packages/util/output-retention/README.i18n.yaml +++ b/packages/util/output-retention/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/output-retention/README.md -README.md: bb0902b959f298f9f7b0a64de23d48e29bfd75ad -README.zh.md: 7d96ba4da33a98b24eba53f9dd5a2f2c98b79f39 +README.md: ec16dd9626f2268a9a019373c3e135619e1ef2e5 +README.zh.md: 1ae37399224838d6b044dacc81a15983f82ff9c8 diff --git a/packages/util/output-retention/README.md b/packages/util/output-retention/README.md index bb0902b959..ec16dd9626 100644 --- a/packages/util/output-retention/README.md +++ b/packages/util/output-retention/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-output-retention` to cap the items or text a tool returns to a model while reporting what was omitted. `ItemRetainer` keeps an ordered head window and can report an exact omitted-item count; `TextRetainer` keeps head, tail, or head-and-tail byte windows without returning invalid UTF-8 cuts. `formatRetentionNotice` adds a consistent omission clause while each tool supplies its own recovery guidance. Grouping, line numbering, spill files, and provider errors remain tool responsibilities; consumers import this library directly rather than loading it through `cordis.yml`. +Use `dsh-output-retention` to cap the items or text a tool returns to a model while reporting what was omitted. `ItemRetainer` keeps an ordered head window and can report an exact omitted-item count; `TextRetainer` keeps head, tail, or head-and-tail byte windows without returning invalid UTF-8 cuts. `formatRetentionNotice` adds a consistent omission clause while each tool supplies its own recovery guidance. `truncateWithoutSplittingSurrogatePair` caps a character-budget preview without leaving a lone high surrogate at the cut. Grouping, line numbering, spill files, and provider errors remain tool responsibilities; consumers import this library directly rather than loading it through `cordis.yml`. ## Table of Contents @@ -104,7 +104,7 @@ The library is built on one separation: it owns the mechanical question of what | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `ItemRetainer`, `TextRetainer`, `describeOmitted`, and `formatRetentionNotice` | +| [`src/index.ts`](src/index.ts) | `ItemRetainer`, `TextRetainer`, `describeOmitted`, `formatRetentionNotice`, and `truncateWithoutSplittingSurrogatePair` | | — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### Two retainers, two resource models diff --git a/packages/util/output-retention/README.zh.md b/packages/util/output-retention/README.zh.md index 7d96ba4da3..1ae3739922 100644 --- a/packages/util/output-retention/README.zh.md +++ b/packages/util/output-retention/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -使用 `dsh-output-retention` 限制工具返回给模型的项或文本量,并报告省略了什么。`ItemRetainer` 保留有序的头部窗口,并可报告精确的省略项数;`TextRetainer` 保留 head、tail 或 head-and-tail 字节窗口,且不会返回因切割而无效的 UTF-8。`formatRetentionNotice` 添加一致的省略子句,各工具则提供自己的恢复指引。分组、行号、spill 文件与提供方错误仍归工具负责;消费方直接导入本库,而不通过 `cordis.yml` 加载。 +使用 `dsh-output-retention` 限制工具返回给模型的项或文本量,并报告省略了什么。`ItemRetainer` 保留有序的头部窗口,并可报告精确的省略项数;`TextRetainer` 保留 head、tail 或 head-and-tail 字节窗口,且不会返回因切割而无效的 UTF-8。`formatRetentionNotice` 添加一致的省略子句,各工具则提供自己的恢复指引。`truncateWithoutSplittingSurrogatePair` 按字符预算截断预览时,不会在切口留下孤立的高位代理项。分组、行号、spill 文件与提供方错误仍归工具负责;消费方直接导入本库,而不通过 `cordis.yml` 加载。 ## 目录 @@ -104,7 +104,7 @@ const footer = formatRetentionNotice( | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `ItemRetainer`、`TextRetainer`、`describeOmitted` 与 `formatRetentionNotice` | +| [`src/index.ts`](src/index.ts) | `ItemRetainer`、`TextRetainer`、`describeOmitted`、`formatRetentionNotice` 与 `truncateWithoutSplittingSurrogatePair` | | — | 不发布运行时不变式伴生入口;这个纯工具不拥有事件流或可变运行时数据;其值代数由单元测试保证。 | ### 两个 retainer,两种资源模型 diff --git a/packages/util/output-retention/src/index.ts b/packages/util/output-retention/src/index.ts index 5091c16374..3c2ba40e43 100644 --- a/packages/util/output-retention/src/index.ts +++ b/packages/util/output-retention/src/index.ts @@ -441,3 +441,20 @@ export function formatRetentionNotice( .filter(part => part.length > 0) .join(' ') } + +/** + * Cap `text` at `maxChars` UTF-16 code units without ending in a lone high + * surrogate: a cut that lands inside a surrogate pair drops the unpaired half, + * so the result is one code unit shorter than the cap. An unpaired surrogate is + * not well-formed text — a strict JSON reader rejects a durable Session log that + * carries one. + * + * @param text The text to cap. + * @param maxChars The maximum number of UTF-16 code units to retain. + * @returns A prefix of `text` no longer than `maxChars`, never ending in a lone high surrogate. + */ +export function truncateWithoutSplittingSurrogatePair(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + const capped = text.slice(0, maxChars) + return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped +} diff --git a/packages/util/output-retention/tests/output-retention.spec.ts b/packages/util/output-retention/tests/output-retention.spec.ts index dfad8eb251..e1f5c93d92 100644 --- a/packages/util/output-retention/tests/output-retention.spec.ts +++ b/packages/util/output-retention/tests/output-retention.spec.ts @@ -6,6 +6,7 @@ import { type Omitted, type RetentionNotice, TextRetainer, + truncateWithoutSplittingSurrogatePair, } from '@deepseek-ai/dsh-output-retention' /** Decode a RetainedText via a round-trip helper for readable UTF-8 assertions. */ @@ -374,3 +375,35 @@ describe('formatRetentionNotice', () => { expect(out).toBe('Omitted 500 bytes. Kept 2000B head + 2000B tail.') }) }) + +describe('truncateWithoutSplittingSurrogatePair', () => { + it('caps at maxChars UTF-16 code units', () => { + expect(truncateWithoutSplittingSurrogatePair('abcdef', 3)).toBe('abc') + expect(truncateWithoutSplittingSurrogatePair('abcdef', 0)).toBe('') + expect(truncateWithoutSplittingSurrogatePair('abcdef', 1)).toBe('a') + }) + + it('returns text within the cap unchanged', () => { + expect(truncateWithoutSplittingSurrogatePair('abc', 3)).toBe('abc') + expect(truncateWithoutSplittingSurrogatePair('abc', 9)).toBe('abc') + expect(truncateWithoutSplittingSurrogatePair('', 0)).toBe('') + }) + + it('drops the unpaired half when the cap lands inside a surrogate pair', () => { + // '😀' is two code units, so these caps would otherwise keep its high surrogate alone. + expect(truncateWithoutSplittingSurrogatePair('ab😀cd', 3)).toBe('ab') + expect(truncateWithoutSplittingSurrogatePair('ab😀cd', 4)).toBe('ab😀') + expect(truncateWithoutSplittingSurrogatePair('😀😀', 1)).toBe('') + expect(truncateWithoutSplittingSurrogatePair('😀😀', 3)).toBe('😀') + }) + + it('never ends in a lone high surrogate at any cap', () => { + const text = 'a😀b😀c' + for (let maxChars = 0; maxChars <= text.length; maxChars++) { + const capped = truncateWithoutSplittingSurrogatePair(text, maxChars) + expect(capped.length).toBeLessThanOrEqual(maxChars) + expect(capped).not.toMatch(/[\uD800-\uDBFF]$/) + expect(capped).toBe(text.slice(0, capped.length)) + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5c3824f8c..83a6919e54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7996,6 +7996,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -10792,6 +10795,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -10914,6 +10920,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../pwsh-local From b0ad792e6bcf4ee451f919347287ff24fefbfb49 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 19:14:08 +0800 Subject: [PATCH 022/253] fix(web): keep dedicated previews and the read-card lang domain The shared code-language table must not claim a suffix a dedicated viewer declares, and the Host read card must keep the `lang` values it already persisted. - Drop `csv` from the shared table: Code registers before Spreadsheet, so the earlier body would take `.csv` and the browser snapshot gate fails. - Add `readLangHintForPath`, the read card's legacy projection over the shared table: the 43 pre-unification suffixes keep their old short ids byte for byte, new suffixes use the canonical id. - Add an ownership regression test over the full preview registry that turns red when `csv` returns, plus byte-level and alias-normalized tests for the read projection. --- .../ui-primitives/src/markdown/highlight.ts | 11 ++- .../tests/code-highlighting.client.spec.tsx | 24 ++++-- .../tests/apply.client.spec.ts | 32 +++++++- packages/fs/tool-fs/src/read-render.ts | 15 ++-- packages/fs/tool-fs/tests/read-render.spec.ts | 75 +++++++++++++++---- packages/fs/tool-fs/tests/tools.spec.ts | 4 +- packages/util/code-language/README.i18n.yaml | 4 +- packages/util/code-language/README.md | 4 +- packages/util/code-language/README.zh.md | 4 +- packages/util/code-language/src/index.ts | 60 +++++++++++++-- .../code-language/tests/code-language.spec.ts | 49 +++++++++++- 11 files changed, 238 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 989d22a711..aece48f580 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -230,13 +230,22 @@ const LANG_ALIASES = new Map([ ['gradle', 'groovy'], ]) +/** + * Resolve a language hint to the grammar id {@link LANG_ALIASES} selects. + * @param lang - Language hint from a code surface: a shared canonical id or a legacy read-card hint. + * @returns The resolved grammar id, or `undefined` when the table aliases no grammar. + */ +export function grammarForHint(lang: string | undefined): string | undefined { + return lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase()) +} + /** * Whether a language hint can use the shared syntax highlighter. * @param lang - Language hint from a code surface. * @returns Whether the hint resolves to a supported grammar. */ export function supportsHighlighting(lang: string | undefined): boolean { - return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase()) + return grammarForHint(lang) !== undefined } /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ diff --git a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx index c8fdde9199..eb8c398992 100644 --- a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, renderHook, waitFor } from '@testing-library/react' -import { languageForPath as sharedLanguageForPath } from '@deepseek-ai/dsh-util-code-language' +import { languageForPath as sharedLanguageForPath, readLangHintForPath } from '@deepseek-ai/dsh-util-code-language' import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts' -import { supportsHighlighting } from '../src/markdown/highlight.ts' +import { grammarForHint, supportsHighlighting } from '../src/markdown/highlight.ts' afterEach(cleanup) @@ -28,7 +28,7 @@ describe('code highlighting', () => { it.each([ ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], ['module.psm1', 'powershell'], ['config.fish', 'fish'], ['app.properties', 'ini'], - ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], ['doc.cls', 'latex'], ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], @@ -46,11 +46,25 @@ describe('code highlighting', () => { }) it('resolves through the one shared extension table, never a local copy', () => { - // A second table re-introduced here fails this identity assertion before it - // can drift from the read card's file-extension hints. + // A second canonical table re-introduced here fails this identity assertion + // before it can drift from the shared extension table. expect(languageForPath).toBe(sharedLanguageForPath) }) + it('reaches one grammar from the shared canonical id and the read card legacy hint', () => { + // The read card persists the old short ids while this surface reads the + // canonical table directly; both must select the same grammar, so a + // persisted hint can never render differently from this surface's own + // selection. This is the alias-normalized check the identity assertion + // cannot make now that the read projection intentionally differs. + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + const path = `file.${extension}` + const readHint = readLangHintForPath(path) + expect(readHint, extension).toBeDefined() + expect(grammarForHint(readHint), extension).toBe(grammarForHint(languageForPath(path))) + } + }) + it('keeps every registered suffix highlightable by the shared primitive', () => { expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 79963d3c42..669c85818c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -77,7 +77,7 @@ async function boot() { const fiber = ctx.plugin({ inject: [...inject], apply }) onTestFinished(async () => { await fiber.dispose() }) await fiber.await() - return { tabs, registered, dictionaries, fiber, read, readBytes } + return { ctx, tabs, registered, dictionaries, fiber, read, readBytes } } describe('ui-sidebar-documentpreview apply', () => { @@ -114,6 +114,36 @@ describe('ui-sidebar-documentpreview apply', () => { expect(dictionaries.size).toBe(0) }) + it('leaves every declared suffix with its owning preview and never lets the shared highlighter claim a later viewer\'s suffix', async () => { + const { ctx } = await boot() + const previews = ctx.get('documentPreviews') + if (previews === undefined) throw new Error('documentPreviews was not provided') + // apply() registers in production order: text, markdown, html, image, pdf, + // code, office, excel. The shared Code body is the generic highlighter, so + // where it collides with an EARLIER dedicated body the earlier body keeps the + // suffix; any other collision means a later dedicated viewer lost a suffix it + // must render. Listing csv under Code makes Spreadsheet the loser. + const sharedHighlighter = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code' + const owners = new Map() + for (const definition of previews.getSnapshot()) { + for (const extension of new Set([...definition.extensions, ...definition.binaryExtensions ?? []])) { + const winner = previews.candidates(`file.${extension}`)[0] + const earlier = owners.get(extension) + if (earlier === undefined) { + expect(winner?.id, `${definition.id} declares .${extension}`).toBe(definition.id) + } else { + expect(winner?.id, `.${extension} must stay with the earlier ${earlier}`).toBe(earlier) + expect(definition.id, `later body ${definition.id} must not collide on .${extension}`).toBe(sharedHighlighter) + } + owners.set(extension, earlier ?? definition.id) + } + } + // Delimited spreadsheets stay with the Spreadsheet viewer, not the Code body. + const excel = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel' + expect(previews.candidates('table.csv')[0]?.id).toBe(excel) + expect(previews.candidates('table.tsv')[0]?.id).toBe(excel) + }) + it('injects paged and byte Remote reads independently of resource metadata', async () => { const { registered, read, readBytes } = await boot() const registration = registered.find(entry => entry.component === TextPreview) diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 7862a4366f..79301214ad 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -170,14 +170,17 @@ ${body} } /** - * Derive a syntax-highlighting language id from a read path's file extension via - * the extension table shared with the Client code surfaces; see - * `@deepseek-ai/dsh-util-code-language` for the table and the path rules (both - * separators, a leading dot as the extension separator, and prototype-key safety). + * Derive the persisted `lang` hint from a read path's file extension. The shared + * table in `@deepseek-ai/dsh-util-code-language` owns the recognized suffixes and + * the path rules (both separators, a leading dot as the extension separator, and + * prototype-key safety); `readLangHintForPath` projects the read card's + * historical short ids over it, so a suffix the pre-unification read table + * recognized keeps its byte-identical old value while a newly added suffix uses + * the canonical id. * @param path - the model-facing path the read reported. - * @returns the language id, or `undefined` when the extension maps to none. + * @returns the persisted language hint, or `undefined` when the extension maps to none. */ -export { languageForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' +export { readLangHintForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' /** * The `read` tool's private `tool/result` `meta` payload: the structured diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index 2e364b8e74..14a22083c0 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { languageForPath } from '@deepseek-ai/dsh-util-code-language' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -118,30 +118,77 @@ describe('buildWindow', () => { }) }) +/** + * The pre-unification read table's extension-to-hint mapping, transcribed + * verbatim. A read card persisted one of these strings, so changing any value + * changes replay-visible output for an already-recorded session. + */ +const MASTER_READ_LANG_BY_EXTENSION: Readonly> = { + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +} + describe('langFromPath', () => { - it('resolves through the one shared extension table, never a local copy', () => { - // The read card and the Client code surfaces must agree on every path; a - // local table re-introduced on either side makes one of these identity - // assertions fail before the two can drift in language hints. - expect(langFromPath).toBe(languageForPath) - for (const path of ['src/a.ts', 'conf.yml', 'README.md', 'build.PS1', 'C:\\src\\main.rs']) { - expect(langFromPath(path), path).toBe(languageForPath(path)) + it('keeps every pre-unification suffix byte-identical to the old persisted hint', () => { + expect(Object.keys(MASTER_READ_LANG_BY_EXTENSION)).toHaveLength(43) + for (const [extension, hint] of Object.entries(MASTER_READ_LANG_BY_EXTENSION)) { + expect(langFromPath(`file.${extension}`), extension).toBe(hint) } }) - it('maps a known extension to its canonical language id, case-insensitively', () => { - expect(langFromPath('src/a.ts')).toBe('typescript') - expect(langFromPath('src/a.TSX')).toBe('typescript') - expect(langFromPath('/abs/module.mjs')).toBe('javascript') + it('uses the shared canonical id for every suffix the old read table did not know', () => { + // The legacy projection is keyed by exactly the old extensions, so a suffix + // the shared table adds later must fall through to its canonical id instead + // of inheriting a stale short hint. + const legacy = new Set(Object.keys(MASTER_READ_LANG_BY_EXTENSION)) + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + if (legacy.has(extension)) continue + expect(langFromPath(`file.${extension}`), extension).toBe(languageForPath(`file.${extension}`)) + } + }) + + it('gives a newly added suffix its shared canonical id', () => { + // These suffixes were undefined in the old build, so they are free to carry + // the canonical grammar id instead of a short hint. + expect(langFromPath('build.ps1')).toBe('powershell') + expect(langFromPath('deploy.bat')).toBe('bat') + expect(langFromPath('.env')).toBe('dotenv') + expect(langFromPath('server.log')).toBe('log') + expect(langFromPath('message.proto')).toBe('proto') + expect(langFromPath('infra.tf')).toBe('hcl') + expect(langFromPath('paper.tex')).toBe('latex') + }) + + it('maps a known extension to its old short hint, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('ts') + expect(langFromPath('src/a.TSX')).toBe('tsx') + expect(langFromPath('/abs/module.mjs')).toBe('js') expect(langFromPath('conf.yml')).toBe('yaml') - expect(langFromPath('README.md')).toBe('markdown') + expect(langFromPath('README.md')).toBe('md') + }) + + it('keeps the canonical ids the Client code surfaces use out of the persisted hint', () => { + // The Client reads the shared table directly; only this projection owns the + // persisted value, so the two intentionally differ for the old suffixes. + expect(languageForPath('src/a.ts')).toBe('typescript') + expect(languageForPath('README.md')).toBe('markdown') + expect(langFromPath('src/a.ts')).not.toBe(languageForPath('src/a.ts')) }) it('reads the extension after the last path segment and last dot', () => { expect(langFromPath('a.py.bak')).toBeUndefined() expect(langFromPath('archive.tar.gz')).toBeUndefined() expect(langFromPath('/dir.py/plain')).toBeUndefined() - expect(langFromPath('C:\\src\\main.rs')).toBe('rust') + expect(langFromPath('C:\\src\\main.rs')).toBe('rs') }) it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ac70df7015..28613a2920 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -352,7 +352,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'typescript', + lang: 'ts', }) const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) expect(view).toEqual({ @@ -361,7 +361,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'typescript', + lang: 'ts', content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], }) }) diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml index e9789fe208..0c35649bcd 100644 --- a/packages/util/code-language/README.i18n.yaml +++ b/packages/util/code-language/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/code-language/README.md -README.md: a65c28cc11e5aa112b5450b9ec42b23dce764c9d -README.zh.md: cc15751853a8ab8b796bf16f989ee001d7347d9e +README.md: adb04ba3283e38d1b708f1831ffd41c0f08ce967 +README.zh.md: c2ced098f48946abdc4affae6b7b3fa09bd439fb diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md index a65c28cc11..adb04ba328 100644 --- a/packages/util/code-language/README.md +++ b/packages/util/code-language/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a filename or path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the extension separator (`.env` resolves to `dotenv`). `CODE_HIGHLIGHT_EXTENSIONS` lists every recognized suffix for a preview registry. The package is browser-safe, has no Cordis service or runtime state, and leaves the actual tokenization to the Client highlighter. +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's older short ids over the table so previously persisted values stay byte-identical. The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. ## Table of Contents @@ -24,7 +24,7 @@ The repository's one file-extension to syntax-highlighting language table, share The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. -The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns its canonical id, and an unrecognized suffix returns `undefined`. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. ----- diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md index cc15751853..c2ced098f4 100644 --- a/packages/util/code-language/README.zh.md +++ b/packages/util/code-language/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片保留统一前的短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀则使用规范 id。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 ## 目录 @@ -24,7 +24,7 @@ kind: "package-library" 表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 -该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 ----- diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts index 09aac2143f..1f7743fee2 100644 --- a/packages/util/code-language/src/index.ts +++ b/packages/util/code-language/src/index.ts @@ -5,6 +5,9 @@ * highlighter's alias table resolves, so a language returned here reaches * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or * one naming a language the highlighter does not register, renders as plain text. + * The read card predates the shared table, so {@link readLangHintForPath} projects + * its historical short ids over this table for the suffixes the old read table + * already recognized, keeping those persisted values byte-identical. * @module @deepseek-ai/dsh-util-code-language */ @@ -17,7 +20,9 @@ * grammar does not exist map to the nearest available grammar * (`properties` to `ini`, whose registration carries the `properties` alias) or * stay unlisted. Certificate and lock extensions (`pem`, `crt`, `key`, `cer`, - * `lock`) intentionally stay unlisted. + * `lock`) stay unlisted, and `csv` is deliberately absent: the Spreadsheet + * preview declares it and must keep it, so listing it here would let the earlier + * Code registration take the suffix. */ const LANGUAGE_EXTENSIONS: Readonly> = { typescript: ['ts', 'tsx', 'mts', 'cts'], @@ -41,7 +46,6 @@ const LANGUAGE_EXTENSIONS: Readonly> = { ini: ['ini', 'conf', 'cfg', 'properties'], dotenv: ['env'], log: ['log'], - csv: ['csv'], diff: ['diff', 'patch'], http: ['http'], markdown: ['md', 'markdown'], @@ -95,6 +99,34 @@ const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) /** Every recognized filename suffix, one entry each; a document-preview registry uses it to claim Code-rendered bodies. */ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] +/** + * The read card's historical short language hints for the suffixes its + * pre-unification table recognized. Keys are shared-table extensions, so the + * recognized set still derives from {@link LANGUAGE_EXTENSIONS}; values preserve + * the old persisted `lang` strings byte for byte. An extension absent here but + * present in the shared table is new and uses its canonical id. + */ +const READ_LANG_BY_EXTENSION = new Map(Object.entries({ + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +})) + +function extensionForPath(path: string): string | undefined { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + const base = path.slice(slash + 1) + const dot = base.lastIndexOf('.') + return dot < 0 ? undefined : base.slice(dot + 1).toLowerCase() +} + /** * Derive the syntax-highlighting language from a filename or path, case * insensitively. The extension is the text after the last dot of the final path @@ -106,9 +138,23 @@ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys() * @returns the canonical language id, or `undefined` for an unrecognized or absent suffix. */ export function languageForPath(path: string): string | undefined { - const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - const base = path.slice(slash + 1) - const dot = base.lastIndexOf('.') - if (dot < 0) return undefined - return LANGUAGES.get(base.slice(dot + 1).toLowerCase()) + const extension = extensionForPath(path) + return extension === undefined ? undefined : LANGUAGES.get(extension) +} + +/** + * Derive the Host read card's persisted `lang` hint from a read path's + * extension. This is the legacy projection the read card writes: a suffix the + * pre-unification read table recognized keeps its old short id (`ts`, `md`, + * `cpp`, …), while a suffix only the shared table knows (`.ps1`, `.env`, + * `.tf`, …) yields its canonical id. An unrecognized suffix stays + * `undefined`, so the card renders as plain text. + * @param path - the model-facing path the read reported. + * @returns the persisted language hint, or `undefined` when the extension maps to none. + */ +export function readLangHintForPath(path: string): string | undefined { + const extension = extensionForPath(path) + if (extension === undefined) return undefined + const canonical = LANGUAGES.get(extension) + return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? canonical } diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts index 0ede43e840..f93681350e 100644 --- a/packages/util/code-language/tests/code-language.spec.ts +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '../src/index.ts' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, readLangHintForPath } from '../src/index.ts' describe('languageForPath', () => { it.each([ @@ -8,7 +8,7 @@ describe('languageForPath', () => { ['module.psm1', 'powershell'], ['data.psd1', 'powershell'], ['config.fish', 'fish'], // Config, data, and text extensions. ['app.properties', 'ini'], ['app.conf', 'ini'], ['app.cfg', 'ini'], - ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['.env', 'dotenv'], ['server.log', 'log'], ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], // Documentation and markup extensions. @@ -79,6 +79,51 @@ describe('languageForPath', () => { expect(languageForPath(`secret.${extension}`), extension).toBeUndefined() } }) + + it('leaves delimited-spreadsheet suffixes to the Spreadsheet preview', () => { + // The Code preview registers before the Spreadsheet preview, so listing csv + // here would make Code the earlier candidate and steal the suffix from the + // viewer that must render it. + for (const extension of ['csv', 'tsv']) { + expect(languageForPath(`table.${extension}`), extension).toBeUndefined() + } + }) +}) + +describe('readLangHintForPath', () => { + it('keeps the read card pre-unification short hints for the old suffixes', () => { + expect(readLangHintForPath('src/a.ts')).toBe('ts') + expect(readLangHintForPath('src/a.tsx')).toBe('tsx') + expect(readLangHintForPath('src/a.mts')).toBe('ts') + expect(readLangHintForPath('src/a.cts')).toBe('ts') + expect(readLangHintForPath('src/a.js')).toBe('js') + expect(readLangHintForPath('src/a.jsx')).toBe('jsx') + expect(readLangHintForPath('src/a.mjs')).toBe('js') + expect(readLangHintForPath('src/a.cjs')).toBe('js') + expect(readLangHintForPath('src/a.jsonc')).toBe('json') + expect(readLangHintForPath('src/a.cc')).toBe('cpp') + expect(readLangHintForPath('src/a.hpp')).toBe('cpp') + expect(readLangHintForPath('src/a.sh')).toBe('sh') + expect(readLangHintForPath('src/a.kt')).toBe('kotlin') + expect(readLangHintForPath('README.md')).toBe('md') + expect(readLangHintForPath('README.markdown')).toBe('md') + expect(readLangHintForPath('page.htm')).toBe('html') + }) + + it('uses the canonical id for a suffix added after the old read table', () => { + expect(readLangHintForPath('build.ps1')).toBe('powershell') + expect(readLangHintForPath('.env')).toBe('dotenv') + expect(readLangHintForPath('infra.tf')).toBe('hcl') + expect(readLangHintForPath('paper.tex')).toBe('latex') + expect(readLangHintForPath('server.log')).toBe('log') + expect(readLangHintForPath('message.proto')).toBe('proto') + }) + + it('returns undefined exactly when the shared table does not recognize the suffix', () => { + for (const path of ['.gitignore', '/etc/hosts', 'trailingdot.', 'data.unknownext', 'foo.constructor']) { + expect(readLangHintForPath(path), path).toBeUndefined() + } + }) }) describe('CODE_HIGHLIGHT_EXTENSIONS', () => { From 6f056da42f5156df6951706c1772b4a62b274f9e Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 21:17:55 +0800 Subject: [PATCH 023/253] chore(deps): keep only the lockfile delta the settings portal needs The consolidated commit refreshed unrelated transitive resolutions while it resolved the settings portal's new react-dom dependency. Restore those entries to their previous versions and keep only the two importer entries the portal needs; the lockfile already carries both snapshots. --- pnpm-lock.yaml | 164 ++++++++++++++++++++----------------------------- 1 file changed, 65 insertions(+), 99 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b88b5fb63..8661bd2da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,10 +65,10 @@ importers: version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.2 + version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -2333,7 +2333,7 @@ importers: version: 15.0.0 open: specifier: ^11.0.0 - version: 11.0.4 + version: 11.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2737,7 +2737,7 @@ importers: version: link:../ui-sidebar '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3193,7 +3193,7 @@ importers: version: link:../ui-workspace '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3223,7 +3223,7 @@ importers: version: link:../ui-workspace '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3247,7 +3247,7 @@ importers: version: link:../ui-primitives '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3313,7 +3313,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3502,7 +3502,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3814,7 +3814,7 @@ importers: version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3976,7 +3976,7 @@ importers: version: 18.3.7(@types/react@18.3.31) '@types/use-sync-external-store': specifier: ^1.5.0 - version: 1.7.0 + version: 1.5.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4024,7 +4024,7 @@ importers: version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4260,7 +4260,7 @@ importers: version: link:../../util/package-manifest '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4389,7 +4389,7 @@ importers: version: link:../ui-slots '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4471,7 +4471,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4543,7 +4543,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4609,7 +4609,7 @@ importers: version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/picomatch': specifier: ^4.0.2 version: 4.0.3 @@ -4687,7 +4687,7 @@ importers: version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4756,7 +4756,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4979,7 +4979,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -6616,7 +6616,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -7901,10 +7901,10 @@ importers: version: link:../../../vendor/schemastery compression: specifier: ^1.8.1 - version: 1.8.2(supports-color@9.4.0) + version: 1.8.1(supports-color@9.4.0) negotiator: specifier: ^1.0.0 - version: 1.1.0 + version: 1.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -11555,10 +11555,10 @@ importers: dependencies: '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.2 + version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) js-yaml: specifier: ^4.2.0 version: 4.3.1 @@ -13521,13 +13521,13 @@ packages: os: [linux] '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1': - resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==, tarball: https://registry.npmjs.org/@deepseek-ai/libreoffice-kit-win32-arm64/-/libreoffice-kit-win32-arm64-0.0.1.tgz} + resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==} engines: {node: '>=22.19.0'} cpu: [arm64] os: [win32] '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1': - resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==, tarball: https://registry.npmjs.org/@deepseek-ai/libreoffice-kit-win32-x64/-/libreoffice-kit-win32-x64-0.0.1.tgz} + resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==} engines: {node: '>=22.19.0'} cpu: [x64] os: [win32] @@ -15616,8 +15616,8 @@ packages: '@tanstack/virtual-core@3.17.7': resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} - '@testing-library/dom@10.4.2': - resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} '@testing-library/react@16.3.2': @@ -15945,8 +15945,8 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@1.7.0': - resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -16692,8 +16692,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.2: - resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -16711,10 +16711,6 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} - content-type@2.1.0: - resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} - engines: {node: '>=18'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -16986,8 +16982,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.1: - resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} defer-to-connect@2.0.1: @@ -17024,10 +17020,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -17573,13 +17565,9 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@7.2.0: - resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -18307,8 +18295,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.1: - resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -18510,9 +18498,9 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} - negotiator@1.1.0: - resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} - engines: {node: '>=18'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} node-abi@3.96.0: resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} @@ -18668,8 +18656,8 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - open@11.0.4: - resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} openai@6.40.0: @@ -18873,10 +18861,6 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - powershell-utils@0.2.1: - resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} - engines: {node: '>=20'} - preact@10.29.7: resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} peerDependencies: @@ -18932,8 +18916,8 @@ packages: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} - proxy-addr@2.0.8: - resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} psl@1.15.0: @@ -20054,8 +20038,8 @@ packages: utf-8-validate: optional: true - wsl-utils@1.0.0: - resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} xml-name-validator@5.0.0: @@ -22413,7 +22397,7 @@ snapshots: '@tanstack/virtual-core@3.17.7': {} - '@testing-library/dom@10.4.2': + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.7 @@ -22424,10 +22408,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.2 + '@testing-library/dom': 10.4.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: @@ -22787,7 +22771,7 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@1.7.0': {} + '@types/use-sync-external-store@1.5.0': {} '@types/web-bluetooth@0.0.21': {} @@ -23152,7 +23136,7 @@ snapshots: accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.1.0 + negotiator: 1.0.0 acorn-jsx@5.3.2(acorn@8.17.0): dependencies: @@ -23591,12 +23575,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.2(supports-color@9.4.0): + compression@1.8.1(supports-color@9.4.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9(supports-color@9.4.0) - destroy: 1.2.0 negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -23612,8 +23595,6 @@ snapshots: content-type@2.0.0: {} - content-type@2.1.0: {} - convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -23917,7 +23898,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.1: + default-browser@5.5.0: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -23952,8 +23933,6 @@ snapshots: dequal@2.0.3: {} - destroy@1.2.0: {} - detect-libc@2.1.2: {} detect-node@2.1.0: @@ -24406,7 +24385,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.8 + proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 router: 2.2.0 @@ -24688,15 +24667,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -25490,7 +25460,7 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -25794,9 +25764,7 @@ snapshots: negotiator@0.6.4: {} - negotiator@1.1.0: - dependencies: - content-type: 2.1.0 + negotiator@1.0.0: {} node-abi@3.96.0: dependencies: @@ -25943,14 +25911,14 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - open@11.0.4: + open@11.0.0: dependencies: - default-browser: 5.5.1 + default-browser: 5.5.0 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.2.1 - wsl-utils: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 openai@6.40.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: @@ -26154,8 +26122,6 @@ snapshots: powershell-utils@0.1.0: {} - powershell-utils@0.2.1: {} - preact@10.29.7: {} prebuild-install@7.1.3: @@ -26220,7 +26186,7 @@ snapshots: '@types/node': 22.20.0 long: 5.3.2 - proxy-addr@2.0.8: + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -26382,7 +26348,7 @@ snapshots: rimraf@2.6.3: dependencies: - glob: 7.2.0 + glob: 7.2.3 roarr@2.15.4: dependencies: @@ -27033,7 +26999,7 @@ snapshots: type-is@2.1.0: dependencies: content-type: 2.0.0 - media-typer: 1.1.1 + media-typer: 1.1.0 mime-types: 3.0.2 typebox@1.3.7: {} @@ -27492,7 +27458,7 @@ snapshots: ws@8.21.0: {} - wsl-utils@1.0.0: + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 From 19b7de31a6c65387739fe07094b41d976f1d3bae Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 12:21:19 +0800 Subject: [PATCH 024/253] refactor(tool-fs): persist the read language hint as a short id --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/highlight.ts | 1 + packages/fs/tool-fs/src/read-render.ts | 2 +- packages/fs/tool-fs/tests/read-render.spec.ts | 37 ++++---- packages/util/code-language/README.i18n.yaml | 4 +- packages/util/code-language/README.md | 4 +- packages/util/code-language/README.zh.md | 4 +- packages/util/code-language/src/index.ts | 92 +++++++++++++++++-- .../code-language/tests/code-language.spec.ts | 41 ++++++++- 11 files changed, 149 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index e25e0e95fc..c222e01007 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 6cb650eabbb237a0e57bdc0fc12547397458a245 -README.zh.md: f37fc9727ebdce9a0c185931fd74b4488a3b19b8 +README.md: f0ae8eaa30043a23fba72eb403921a38e87ee3a5 +README.zh.md: 0c97794567cea572804051184473e6695ecc46a0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 6cb650eabb..f0ae8eaa30 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -64,7 +64,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | -| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card. | +| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card's persisted short-id `lang` hint. | Four pairs are easy to confuse: diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f37fc9727e..0c97794567 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -64,7 +64,7 @@ kind: "package-library" | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | -| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片背后的同一张扩展名表。 | +| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片持久化短 id `lang` 提示背后的同一张扩展名表。 | 有四组容易混淆: diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index aece48f580..9cd05d7f5d 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -208,6 +208,7 @@ const LANG_ALIASES = new Map([ ['pl', 'perl'], ['pm', 'perl'], ['verilog', 'verilog'], + ['v', 'verilog'], ['system-verilog', 'system-verilog'], ['systemverilog', 'system-verilog'], ['sv', 'system-verilog'], diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 79301214ad..23a27a210f 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -176,7 +176,7 @@ ${body} * prototype-key safety); `readLangHintForPath` projects the read card's * historical short ids over it, so a suffix the pre-unification read table * recognized keeps its byte-identical old value while a newly added suffix uses - * the canonical id. + * its language's short name. * @param path - the model-facing path the read reported. * @returns the persisted language hint, or `undefined` when the extension maps to none. */ diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index 14a22083c0..5f679caff9 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' +import { languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -145,27 +145,26 @@ describe('langFromPath', () => { } }) - it('uses the shared canonical id for every suffix the old read table did not know', () => { - // The legacy projection is keyed by exactly the old extensions, so a suffix - // the shared table adds later must fall through to its canonical id instead - // of inheriting a stale short hint. - const legacy = new Set(Object.keys(MASTER_READ_LANG_BY_EXTENSION)) - for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { - if (legacy.has(extension)) continue - expect(langFromPath(`file.${extension}`), extension).toBe(languageForPath(`file.${extension}`)) - } - }) - - it('gives a newly added suffix its shared canonical id', () => { - // These suffixes were undefined in the old build, so they are free to carry - // the canonical grammar id instead of a short hint. - expect(langFromPath('build.ps1')).toBe('powershell') + it('gives a suffix added after the old read table the language short id', () => { + // A suffix the old read table did not know used to persist the canonical + // grammar id; it now persists the language's short name. + expect(langFromPath('build.ps1')).toBe('ps1') expect(langFromPath('deploy.bat')).toBe('bat') - expect(langFromPath('.env')).toBe('dotenv') + expect(langFromPath('.env')).toBe('env') expect(langFromPath('server.log')).toBe('log') expect(langFromPath('message.proto')).toBe('proto') - expect(langFromPath('infra.tf')).toBe('hcl') - expect(langFromPath('paper.tex')).toBe('latex') + expect(langFromPath('infra.tf')).toBe('tf') + expect(langFromPath('paper.tex')).toBe('tex') + expect(langFromPath('model.jl')).toBe('jl') + expect(langFromPath('top.v')).toBe('v') + expect(langFromPath('build.gradle')).toBe('gradle') + // A later suffix of an already-known language follows that language's short name. + expect(langFromPath('app.conf')).toBe('ini') + expect(langFromPath('task.rake')).toBe('rb') + expect(langFromPath('events.jsonl')).toBe('json') + expect(langFromPath('page.xhtml')).toBe('html') + expect(langFromPath('logo.svg')).toBe('xml') + expect(langFromPath('notebook.ipynb')).toBe('json') }) it('maps a known extension to its old short hint, case-insensitively', () => { diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml index 0c35649bcd..4d0ca4561a 100644 --- a/packages/util/code-language/README.i18n.yaml +++ b/packages/util/code-language/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/code-language/README.md -README.md: adb04ba3283e38d1b708f1831ffd41c0f08ce967 -README.zh.md: c2ced098f48946abdc4affae6b7b3fa09bd439fb +README.md: cf3fbd0e95d689440c10ce3522616a20b86552a4 +README.zh.md: 7476f5a02b07356d602e96f00927944718b572df diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md index adb04ba328..cf3fbd0e95 100644 --- a/packages/util/code-language/README.md +++ b/packages/util/code-language/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's older short ids over the table so previously persisted values stay byte-identical. The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's persisted `lang` onto short ids: the suffixes the old read table recognized keep their historical value byte-identical, and every later suffix uses its language's short name (`powershell` to `ps1`, `hcl` to `tf`). The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. ## Table of Contents @@ -24,7 +24,7 @@ The repository's one file-extension to syntax-highlighting language table, share The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. -The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns its canonical id, and an unrecognized suffix returns `undefined`. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns the language's short name (`powershell` to `ps1`, `hcl` to `tf`), and an unrecognized suffix returns `undefined` — so the persisted field holds one style, a short id, never a canonical grammar id. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. ----- diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md index c2ced098f4..7476f5a02b 100644 --- a/packages/util/code-language/README.zh.md +++ b/packages/util/code-language/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片保留统一前的短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀则使用规范 id。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片持久化的 `lang` 一律是短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀使用该语言的短名(`powershell`→`ps1`、`hcl`→`tf`)。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 ## 目录 @@ -24,7 +24,7 @@ kind: "package-library" 表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 -该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。`readLangHintForPath` 在同一张表上做投影:旧 read 表已识别的后缀返回历史短 id,只有本表才知道的后缀返回该语言的短名(`powershell`→`ps1`、`hcl`→`tf`),无法识别的后缀返回 `undefined`——持久化字段因此只有一种风格,即短 id,绝不出现规范 grammar id。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 ----- diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts index 1f7743fee2..dd4a37115a 100644 --- a/packages/util/code-language/src/index.ts +++ b/packages/util/code-language/src/index.ts @@ -6,8 +6,9 @@ * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or * one naming a language the highlighter does not register, renders as plain text. * The read card predates the shared table, so {@link readLangHintForPath} projects - * its historical short ids over this table for the suffixes the old read table - * already recognized, keeping those persisted values byte-identical. + * short ids over this table: the suffixes the old read table already recognized + * keep their historical values byte-identical, and every later suffix persists the + * canonical language's short name rather than its grammar id. * @module @deepseek-ai/dsh-util-code-language */ @@ -104,7 +105,8 @@ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys() * pre-unification table recognized. Keys are shared-table extensions, so the * recognized set still derives from {@link LANGUAGE_EXTENSIONS}; values preserve * the old persisted `lang` strings byte for byte. An extension absent here but - * present in the shared table is new and uses its canonical id. + * present in the shared table falls back to {@link SHORT_BY_LANGUAGE}, so every + * persisted hint is a short id. */ const READ_LANG_BY_EXTENSION = new Map(Object.entries({ ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', @@ -120,6 +122,78 @@ const READ_LANG_BY_EXTENSION = new Map(Object.entries({ sql: 'sql', xml: 'xml', lua: 'lua', })) +/** + * The short `lang` id the read card persists for each canonical language the + * pre-unification read table did not recognize. Keys are exactly the languages + * in {@link LANGUAGE_EXTENSIONS}: the 27 languages the old read table covered + * repeat their historical short id, and the remaining 33 shorten the grammar id + * (`powershell` to `ps1`, `hcl` to `tf`, `system-verilog` to `sv`). + * Keys are canonical ids, not untrusted extensions, so an object lookup cannot + * reach an `Object.prototype` member the way the extension tables can. + */ +const SHORT_BY_LANGUAGE: Readonly> = { + typescript: 'ts', + javascript: 'js', + shellscript: 'sh', + fish: 'fish', + json: 'json', + python: 'py', + ruby: 'rb', + go: 'go', + rust: 'rs', + java: 'java', + c: 'c', + cpp: 'cpp', + csharp: 'cs', + kotlin: 'kotlin', + swift: 'swift', + php: 'php', + yaml: 'yaml', + toml: 'toml', + ini: 'ini', + dotenv: 'env', + log: 'log', + diff: 'diff', + http: 'http', + markdown: 'md', + mdx: 'mdx', + rst: 'rst', + latex: 'tex', + bibtex: 'bib', + asciidoc: 'adoc', + html: 'html', + css: 'css', + scss: 'scss', + less: 'less', + sql: 'sql', + xml: 'xml', + lua: 'lua', + bat: 'bat', + powershell: 'ps1', + r: 'r', + julia: 'jl', + dart: 'dart', + scala: 'scala', + clojure: 'clj', + erlang: 'erl', + elixir: 'ex', + haskell: 'hs', + fsharp: 'fs', + vb: 'vb', + perl: 'pl', + verilog: 'v', + 'system-verilog': 'sv', + graphql: 'graphql', + proto: 'proto', + hcl: 'tf', + nix: 'nix', + vue: 'vue', + svelte: 'svelte', + make: 'make', + cmake: 'cmake', + groovy: 'gradle', +} + function extensionForPath(path: string): string | undefined { const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) const base = path.slice(slash + 1) @@ -144,11 +218,11 @@ export function languageForPath(path: string): string | undefined { /** * Derive the Host read card's persisted `lang` hint from a read path's - * extension. This is the legacy projection the read card writes: a suffix the - * pre-unification read table recognized keeps its old short id (`ts`, `md`, - * `cpp`, …), while a suffix only the shared table knows (`.ps1`, `.env`, - * `.tf`, …) yields its canonical id. An unrecognized suffix stays - * `undefined`, so the card renders as plain text. + * extension. The hint is always a short id: a suffix the pre-unification read + * table recognized keeps its old value (`ts`, `md`, `cpp`, …), while a suffix + * only the shared table knows (`.ps1`, `.env`, `.tf`, …) uses its language's + * short name (`ps1`, `env`, `tf`, …) rather than the canonical grammar id. An + * unrecognized suffix stays `undefined`, so the card renders as plain text. * @param path - the model-facing path the read reported. * @returns the persisted language hint, or `undefined` when the extension maps to none. */ @@ -156,5 +230,5 @@ export function readLangHintForPath(path: string): string | undefined { const extension = extensionForPath(path) if (extension === undefined) return undefined const canonical = LANGUAGES.get(extension) - return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? canonical + return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? SHORT_BY_LANGUAGE[canonical] } diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts index f93681350e..d03b02d719 100644 --- a/packages/util/code-language/tests/code-language.spec.ts +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -110,15 +110,46 @@ describe('readLangHintForPath', () => { expect(readLangHintForPath('page.htm')).toBe('html') }) - it('uses the canonical id for a suffix added after the old read table', () => { - expect(readLangHintForPath('build.ps1')).toBe('powershell') - expect(readLangHintForPath('.env')).toBe('dotenv') - expect(readLangHintForPath('infra.tf')).toBe('hcl') - expect(readLangHintForPath('paper.tex')).toBe('latex') + it('uses the language short id for a suffix added after the old read table', () => { + expect(readLangHintForPath('build.ps1')).toBe('ps1') + expect(readLangHintForPath('.env')).toBe('env') + expect(readLangHintForPath('infra.tf')).toBe('tf') + expect(readLangHintForPath('paper.tex')).toBe('tex') expect(readLangHintForPath('server.log')).toBe('log') expect(readLangHintForPath('message.proto')).toBe('proto') }) + // The short id each canonical language persists. Transcribed rather than + // imported so this test fails when a canonical grammar id is put back into the + // persisted value, which reading the implementation's own table would not catch. + const SHORT_BY_LANGUAGE: Readonly> = { + typescript: 'ts', javascript: 'js', shellscript: 'sh', fish: 'fish', json: 'json', + python: 'py', ruby: 'rb', go: 'go', rust: 'rs', java: 'java', c: 'c', cpp: 'cpp', + csharp: 'cs', kotlin: 'kotlin', swift: 'swift', php: 'php', yaml: 'yaml', + toml: 'toml', ini: 'ini', dotenv: 'env', log: 'log', diff: 'diff', http: 'http', + markdown: 'md', mdx: 'mdx', rst: 'rst', latex: 'tex', bibtex: 'bib', + asciidoc: 'adoc', html: 'html', css: 'css', scss: 'scss', less: 'less', sql: 'sql', + xml: 'xml', lua: 'lua', bat: 'bat', powershell: 'ps1', r: 'r', julia: 'jl', + dart: 'dart', scala: 'scala', clojure: 'clj', erlang: 'erl', elixir: 'ex', + haskell: 'hs', fsharp: 'fs', vb: 'vb', perl: 'pl', verilog: 'v', + 'system-verilog': 'sv', graphql: 'graphql', proto: 'proto', hcl: 'tf', nix: 'nix', + vue: 'vue', svelte: 'svelte', make: 'make', cmake: 'cmake', groovy: 'gradle', + } + + it('persists a short id for every suffix, never a canonical grammar id', () => { + // The persisted `lang` has one style: the historical short id for the + // suffixes the old read table knew, the language's short name otherwise. A + // result outside both sets is a canonical grammar id leaking back in + // (`powershell`, `dotenv`, `latex`, `hcl`). The old table's `tsx`/`jsx` + // hints are the only legacy values absent from the short-name table. + const allowed = new Set([...Object.values(SHORT_BY_LANGUAGE), 'tsx', 'jsx']) + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + const hint = readLangHintForPath(`file.${extension}`) + expect(hint, extension).toBeDefined() + expect(allowed.has(hint ?? ''), extension).toBe(true) + } + }) + it('returns undefined exactly when the shared table does not recognize the suffix', () => { for (const path of ['.gitignore', '/etc/hosts', 'trailingdot.', 'data.unknownext', 'foo.constructor']) { expect(readLangHintForPath(path), path).toBeUndefined() From f21098e9d32372ebe86605f81e392d4abecd88d7 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:41:33 +0800 Subject: [PATCH 025/253] fix(account): expire rejected credentials and hide signed-out provider --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/account-backend.ts | 4 ++ apps/desktop/tests/account-backend.spec.ts | 7 +++ apps/desktop/tests/welcome-startup.spec.ts | 24 +++++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tests/browser-plugin.client.spec.ts | 21 +++++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../src/client/AccountMenu.tsx | 8 ++- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 11 ++++ .../tests/expected/expired-en.txt | 1 + .../tests/expected/expired-zh.txt | 1 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../ui-settings-models/src/client/store.ts | 2 +- .../tests/store.client.spec.ts | 26 ++++++++- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 + .../deepseek-account-platform/README.zh.md | 2 + .../deepseek-account-platform/src/details.ts | 8 ++- .../deepseek-account-platform/src/index.ts | 37 +++++++++++-- .../deepseek-account-platform/src/protocol.ts | 9 +++ .../tests/account.spec.ts | 55 +++++++++++++++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/types.ts | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 35 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/expired-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/expired-zh.txt diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d6d13bc0fb..1bf1514fdd 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6107698a58cbbe76215d9f23607dabfb3031f8fd -README.zh.md: d85acc25c3bfdebf8929d74cba10e36ca7e8c64a +README.md: 5b9805dab3ee15bc32bdae9a62233ba2c64cadb0 +README.zh.md: bb3500dc834bc2d00f79984b4733715197c190e8 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6107698a58..5b9805dab3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,6 +14,8 @@ Embedded Platform documents use a dedicated non-persistent WebContentsView sessi Desktop Host Platform API requests use the same `x-client-platform` mapping as update-policy requests. The account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package). +A server-expired account credential returns to Welcome when no official API key is configured; an available API key keeps the workspace open. Explicit sign-out follows the same rule. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d85acc25c3..bb3500dc83 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,6 +14,8 @@ Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-client-platform` 映射。账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。 +账号凭据被服务端判定失效后,未配置官方 API key 时返回 Welcome;有可用 API key 时保持工作区打开。主动退出登录遵循相同规则。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 0db791f631..7b60e55db7 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -20,6 +20,9 @@ export function accountView(value: unknown): AccountView { } validateBrowserDestination(value.links.usageUrl) validateBrowserDestination(value.links.topUpUrl) + if ('signOutReason' in value && value.signOutReason !== 'expired') { + throw new Error('desktop account: invalid sign-out reason') + } const attempt = value.attempt if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string' || !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase)) @@ -34,6 +37,7 @@ export function accountView(value: unknown): AccountView { const parsed = value as AccountView return { status: parsed.status, + ...parsed.signOutReason === undefined ? {} : { signOutReason: parsed.signOutReason }, links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl }, attempt: parsed.attempt === null ? null : { id: parsed.attempt.id, phase: parsed.attempt.phase, diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index 9c25ae1026..11217454ac 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -24,3 +24,10 @@ it('uses account Remote commands without returning additional wire fields', asyn expect(await backend.start('en')).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }]) }) + +it('preserves the safe expiration reason and rejects unknown reasons', () => { + const state = { status: 'signed-out', attempt: null, signOutReason: 'expired', + links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } + expect(accountView(state)).toEqual(state) + expect(() => accountView({ ...state, signOutReason: 'unexpected' })).toThrow('invalid sign-out reason') +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 5e12c279d6..5d29254c2f 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -13,6 +13,7 @@ const state = vi.hoisted(() => ({ beforeRead: vi.fn(async () => {}), beforeWelcome: vi.fn(async () => {}), copy: vi.fn(), + accountListener: undefined as ((value: AccountView) => void) | undefined, accountState: vi.fn<() => Promise>().mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' }, }), @@ -24,6 +25,7 @@ const state = vi.hoisted(() => ({ closeWelcome: vi.fn(), welcomeLocale: undefined as DesktopLocale | undefined, preference: 'zh', + hasApiKey: false, handlers: new Map unknown>(), listeners: new Map void>(), contents: undefined as { mainFrame: { url: string } } | undefined, @@ -87,7 +89,9 @@ vi.mock('../src/host-process.ts', () => ({ start = state.startHost stop = state.stopHost fetch() { - return Promise.resolve(Response.json({ loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference })) + return Promise.resolve(Response.json({ + loggedIn: false, hasApiKey: state.hasApiKey, writable: true, localePreference: state.preference, + })) } }, })) @@ -95,10 +99,13 @@ vi.mock('../src/welcome-backend.ts', () => ({ connectDesktopWelcome: async () => ({ read: async () => { await state.beforeRead() - return { loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference } + return { loggedIn: false, hasApiKey: state.hasApiKey, writable: true, localePreference: state.preference } }, save: async () => ({ ok: true }), - account: { watch: () => () => {}, state: state.accountState }, + account: { + watch: (listener: (value: AccountView) => void) => { state.accountListener = listener; return () => {} }, + state: state.accountState, + }, }), })) vi.mock('node:fs/promises', async importOriginal => ({ @@ -201,4 +208,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho changed(event, 'en') expect(state.menu).toHaveBeenCalledTimes(initialMenus + 1) expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' }) + const welcomeCount = state.beforeWelcome.mock.calls.length + state.hasApiKey = true + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + state.hasApiKey = false + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + }) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3b89b5ced5..99b74a71ee 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 57e2c00c98ab2cf09757d78e93fff042156918b1 -config-catalog.zh.md: f197659787958d902a3069ac889b442dcad46d6b +config-catalog.md: 982333d8b645530f0e7a51440b1cfc9f74c86a1b +config-catalog.zh.md: bf195a16ebbc9e09eb8d6e5b79bb2671ee966b77 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57e2c00c98..982333d8b6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -639,7 +639,7 @@ export interface Config { } ``` -Source: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts) +Source: [`packages/credentials/deepseek-account-platform/src/index.ts:24`](../packages/credentials/deepseek-account-platform/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f197659787..bf195a16eb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -641,7 +641,7 @@ export interface Config { } ``` -来源: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts) +来源: [`packages/credentials/deepseek-account-platform/src/index.ts:24`](../packages/credentials/deepseek-account-platform/src/index.ts) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ffd02ee21b..eca46c5e64 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -298,6 +298,27 @@ describe('ui-model-selection dual entry', () => { } }) + it('removes account models from the picker after sign-out', async () => { + const b = await bench('en') + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }, ...GROUPS]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const before = await b.popup().options(projection('s1'), new AbortController().signal) + expect(before.some(option => option.detail?.includes('DeepSeek Account'))).toBe(true) + b.setGroups(GROUPS) + b.remote.emit('credentials/record-updated', ['deepseek-account-platform']) + await vi.waitFor(() => { + expect(b.ctx.modelDirectories.directoryFor(sid('s1')).store.getSnapshot().groups).toEqual(GROUPS) + }) + const after = await b.popup().options(projection('s1'), new AbortController().signal) + expect(after.some(option => option.detail?.includes('DeepSeek Account'))).toBe(false) + expect(after.length).toBeGreaterThan(0) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a3c8fbdf6f..eb121fc25e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 -README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d +README.md: 54f4461573de456afc29fadabb28d29528199e74 +README.zh.md: 31fb4e76c97fb65aa278bf8c354e6dab169764a4 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 615d2ef346..54f4461573 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Server-expired account credentials show a localized sign-in reminder toast and clear the displayed account details. + ## Summary The Account settings section displays DeepSeek login state and offers browser sign-in and cancellation; the sidebar account menu provides Platform sign-out. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 39d8a94b2e..31fb4e76c9 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +账号凭据被服务端判定失效后,显示本地化的重新登录提示 toast,并清空显示的账号详情。 + ## 概述 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器登录和取消;侧边栏账号菜单提供 Platform 退出登录。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 7570a13190..6a1476175a 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -1,6 +1,6 @@ /** Sidebar account launcher and locally authoritative sign-out action. */ -import { useState } from 'react' -import { Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' +import { useEffect, useState } from 'react' +import { Toast, Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountSectionInjected } from './AccountSection.tsx' import { SignOutDialog } from './SignOutDialog.tsx' @@ -21,6 +21,9 @@ export function AccountMenu({ }: AccountMenuProps) { const account = useAccount(state => state) const signedIn = account.view?.status === 'credential-stored' + const expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired' + const [expiryNotice, setExpiryNotice] = useState(false) + useEffect(() => { setExpiryNotice(expired) }, [expired]) const profile = account.details?.profile const label = profile === undefined ? null : profile.status === 'ready' ? profile.value.name ?? profile.value.contact ?? t('signedIn') : t('signedIn') @@ -36,6 +39,7 @@ export function AccountMenu({ finally { setBusy(false) } } return
+ {expiryNotice && { setExpiryNotice(false) }} />} { setOpen(value => !value) }}> diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index e3a07f3f8f..fad00a6775 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -1,5 +1,6 @@ /** Account settings copy, owned by the account feature. */ export const en = { + sessionExpired: 'You have signed out of your account, please log in again.', close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again', loginTitle: 'Start creating', loginDescription: 'Sign in to create, edit, and share your design projects. Everything is saved locally.', browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied', copyFailed: 'Copy failed', @@ -27,6 +28,7 @@ export const en = { export type AccountKey = keyof typeof en /** Chinese account settings copy. */ export const zh: Record = { + sessionExpired: '登录信息已失效,请重新登录', close: '关闭', addApiKey: '添加 API Key', retry: '重新登录', loginTitle: '开始你的创作', loginDescription: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。', browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index dbf81ece0e..3cfb77c5fc 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -243,3 +243,14 @@ it('opens more account information externally without invoking the embedded Plat fireEvent.click(link) expect(platform.open).not.toHaveBeenCalled() }) + +it.each([en, zh])('shows a localized toast when the account credential expires', async (copy) => { + const operations = mount({ status: 'signed-out', attempt: null, signOutReason: 'expired' }, copy) + cleanup() + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + render( selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}} + t={key => key in copy ? copy[key as AccountKey] : key} />) + expect(screen.getByRole('alert').textContent).toContain(copy.sessionExpired) + await expect(`${screen.getByRole('alert').textContent}\n`).toMatchFileSnapshot(`./expected/expired-${copy === en ? 'en' : 'zh'}.txt`) +}) diff --git a/packages/client/ui-settings-account/tests/expected/expired-en.txt b/packages/client/ui-settings-account/tests/expected/expired-en.txt new file mode 100644 index 0000000000..4187486bfe --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/expired-en.txt @@ -0,0 +1 @@ +You have signed out of your account, please log in again. diff --git a/packages/client/ui-settings-account/tests/expected/expired-zh.txt b/packages/client/ui-settings-account/tests/expected/expired-zh.txt new file mode 100644 index 0000000000..7995f21137 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/expired-zh.txt @@ -0,0 +1 @@ +登录信息已失效,请重新登录 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3147eeab54..3854ee9364 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf -README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb +README.md: 99b090dcdda887e75b9e6bee6eb73495d9235af1 +README.zh.md: e89be1ab2959daf2758209c376ea6468d7953ed7 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 289a0ec0b3..99b090dcdd 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -59,7 +59,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. Account availability follows the stored account credential. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 10c61a464d..e89be1ab29 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -59,7 +59,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用性取决于已保存的账号凭据。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 ----- diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f693b5960d..34a20107e3 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -240,7 +240,7 @@ export class ModelsSettingsStore { s.error = null s.credentialError = credentialError s.writable = writable - s.rows = rows.map((row) => { + s.rows = rows.filter(row => row.entry.provider !== 'deepseek-account' || row.accountAvailable === true).map((row) => { if (row.entry.provider === 'deepseek-account') return row const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 5a56062da4..85b2347122 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -350,8 +350,28 @@ it.each([false, true])('uses account availability without asking for an API key: }] }) }) const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() - const row = store.store.getSnapshot().rows[0]! - expect(row).toMatchObject({ accountAvailable, apiKeyEnv: undefined, credential: undefined }) - expect(providerUsable(row)).toBe(accountAvailable) + const rows = store.store.getSnapshot().rows + expect(rows).toHaveLength(accountAvailable ? 1 : 0) + if (accountAvailable) { + expect(rows[0]).toMatchObject({ accountAvailable: true, apiKeyEnv: undefined, credential: undefined }) + expect(providerUsable(rows[0]!)).toBe(true) + } expect(seenRefs).toEqual([]) }) + +it('removes the account row after sign-out and restores it after sign-in', async () => { + const overrides = { accountAvailable: true, providers: async () => ok({ providers: [{ + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + }, ...DIRECTORY] }) } + const { ctx, mirror } = api(overrides) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) + await store.load() + expect(store.store.getSnapshot().rows[0]?.entry.provider).toBe('deepseek-account') + overrides.accountAvailable = false + await store.load() + expect(store.store.getSnapshot().rows.map(row => row.entry.provider)).not.toContain('deepseek-account') + expect(store.store.getSnapshot().rows).toHaveLength(DIRECTORY.length) + overrides.accountAvailable = true + await store.load() + expect(store.store.getSnapshot().rows[0]?.entry.provider).toBe('deepseek-account') +}) diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index b896927357..236da44ed1 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: 31172b1749428decebed41fac99e3fc34a70cf6d -README.zh.md: bdff2428021b7452865cb10d6774fff618c3b111 +README.md: 16cb23d805b15e7c2c0d3f6a0b71f52a35626f7a +README.zh.md: 63c2ec09f0ccae7ee7e3f6388974c5244676e939 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 31172b1749..16cb23d805 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,6 +13,8 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. The Desktop profile supplies `darwin` or `win32`; Host authorization, profile, balance, and logout requests then carry `x-client-platform: desktop-mac` or `desktop-win`, overriding that header in deployment configuration. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. Other profiles omit the automatic header. +Profile and balance HTTP 401 responses clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. + ## Summary Sign in through the system browser and keep the account credential in the existing local credential store. Local cancellation prevents late callbacks and exchange responses from signing the user in. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index bdff242802..63c2ec09f0 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,6 +13,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。Desktop profile 提供 `darwin` 或 `win32`,使 Host 的授权、资料、余额和退登请求携带 `x-client-platform: desktop-mac` 或 `desktop-win`,覆盖部署配置中的同名请求头。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头;其他 profile 不自动添加平台请求头。 +资料和余额接口返回 HTTP 401 时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 + ## 概述 通过系统浏览器登录,并将账号凭证保存在现有本地凭证存储中。本地取消会阻止迟到的回调和兑换响应使用户登录。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index d77606bd7b..a7847d0ca8 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -1,7 +1,7 @@ /** Platform Web profile and wallet queries projected for account UI consumers. */ import { z } from 'zod' import type { AccountDetails, AccountProfile, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' -import { PlatformAuthError, requestAccount } from './protocol.ts' +import { AccountUnauthorizedError, PlatformAuthError, requestAccount } from './protocol.ts' const user = z.object({ id: z.string().nullish(), @@ -48,10 +48,14 @@ const queries: { [K in keyof AccountDetails]: { * @param signal - request and credential lifetime. * @param headers - validated deployment headers for the configured origin. * @returns sanitized query outcome; failure never becomes a zero balance. + * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401. */ export async function readAccountDetail(field: K, origin: string, token: string, signal: AbortSignal, headers: Record): Promise { const query = queries[field] try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) } - catch { return { status: 'failed' } } + catch (error) { + if (error instanceof AccountUnauthorizedError) throw error + return { status: 'failed' } + } } diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 835b79d7da..c41ec18100 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -10,6 +10,7 @@ import { z } from 'zod' import { DeepSeekAccount, desktopClientHeaders, mergePlatformCookies, type PlatformSession, type AccountDetails, type AccountView, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account' import { credentialKey } from '@deepseek-ai/dsh-credentials' import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { AccountUnauthorizedError } from './protocol.ts' import { profile, readAccountDetail } from './details.ts' import { revokeAccount, type LogoutRetryPolicy } from './logout.ts' import { PlatformAuthError, platformHeaders, platformOrigin, browserUrl, requestPlatform, initialization, exchange, loginOrigin } from './protocol.ts' @@ -98,6 +99,7 @@ export class PlatformAccount extends DeepSeekAccount { private detailsLifetime = new AbortController() private closed = false private removing: Promise | undefined + private signOutReason: 'expired' | undefined /** @param ctx - Host with authorization and credentials services. @param config - deployment options. */ constructor(ctx: Context, config: Config = {}) { @@ -170,6 +172,7 @@ export class PlatformAccount extends DeepSeekAccount { throw new PlatformAuthError('storage') } return { + ...(record === undefined && this.signOutReason !== undefined ? { signOutReason: this.signOutReason } : {}), status: record === undefined ? 'signed-out' : 'credential-stored', attempt: this.attempt?.view ?? null, links: { usageUrl: new URL('/usage', this.origin).href, topUpUrl: new URL('/top_up', this.origin).href }, } @@ -200,10 +203,34 @@ export class PlatformAccount extends DeepSeekAccount { delete this.attempt.initialProfile return initial as AccountDetails[K] } - const details = await readAccountDetail(field, this.origin, parsed.data.token, - AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), - { ...this.accountRequestHeaders, ...this.clientHeaders }) - return this.detailsLifetime !== lifetime ? null : details + try { + const details = await readAccountDetail(field, this.origin, parsed.data.token, + AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), + { ...this.accountRequestHeaders, ...this.clientHeaders }) + return this.detailsLifetime !== lifetime ? null : details + } catch (error) { + if (!(error instanceof AccountUnauthorizedError)) throw error + if (this.detailsLifetime === lifetime) await this.expireCredential(parsed.data.token, lifetime) + return null + } + } + + private async expireCredential(token: string, lifetime: AbortController): Promise { + this.removing ??= (async () => { + if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) + const record = await this.ctx.credentials.readRecord(KEY) + if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState() + const current = grant.parse(record.payload) + if (current.token !== token || current.issuer !== this.origin) return this.getState() + this.signOutReason = 'expired' + try { await this.ctx.credentials.deleteRecord(KEY) } + catch (error) { this.signOutReason = undefined; throw error } + this.attempt = undefined + this.ctx.emit('deepseek-account/signed-out') + this.changed() + return this.getState() + })().finally(() => { this.removing = undefined }) + await this.removing } override async getPlatformSession(): Promise { @@ -241,6 +268,7 @@ export class PlatformAccount extends DeepSeekAccount { override async startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise { if (this.removing !== undefined) await this.removing + this.signOutReason = undefined const origin = loginOrigin(callbackOrigin) if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(this.attempt.view.phase)) { @@ -299,6 +327,7 @@ export class PlatformAccount extends DeepSeekAccount { } override signOut(): Promise { + this.signOutReason = undefined this.removing ??= (async () => { if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index c437f67ac3..092518c4ad 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -7,6 +7,11 @@ export class PlatformAuthError extends Error { constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } } +/** An authenticated Platform request was rejected with HTTP 401. */ +export class AccountUnauthorizedError extends PlatformAuthError { + constructor() { super('expired') } +} + /** * Accept HTTPS platform endpoints, or explicitly configured loopback development HTTP. * @param value - configured origin. @@ -132,6 +137,10 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign throw new PlatformAuthError('network') } console.info('[deepseek-account] response', { path, status: response.status }) + if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) { + await response.body?.cancel() + throw new AccountUnauthorizedError() + } if (!response.ok || response.body === null) { await response.body?.cancel() throw new PlatformAuthError('network') diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index d04dc498ce..e690a5ccfc 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -45,6 +45,7 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailStatus = 200 let profileFailed = false let summaryFailed = false let logoutFailed = false @@ -75,8 +76,10 @@ async function fixture( } if (req.method === 'GET') { detailRequests.push({ path: req.url!, authorization: req.headers['x-dsh-auth-token'] as string | undefined }) + const status = detailStatus detailsStarted.resolve(undefined) if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise + if (status !== 200) { res.writeHead(status).end(); return } const value = req.url === '/auth-api/v0/users/current' ? { id: 'test-user', token: 'never-copy-response-token', ...contact, id_profile: { name: 'Test Account', picture: null } } @@ -151,6 +154,7 @@ async function fixture( ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, holdLogout: () => { logoutHold = true }, failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + detailStatus: (status: number) => { detailStatus = status }, failProfile: (failed: boolean) => { profileFailed = failed }, holdBalance: () => { balanceHold = true }, holdDetails: () => { detailsHold = true }, failSummary: () => { summaryFailed = true }, @@ -801,3 +805,54 @@ it.each([undefined, [{ currency: 'EUR', balance: '1' }], [{ currency: 'CNY', bal expect(await f.account.getBalance()).toEqual({ status: 'failed' }) }, ) + +it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 401', async (field) => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailStatus(401) + expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() + expect(signedOut).toBe(1) +}) + +it('coalesces simultaneous unauthorized profile and balance responses', async () => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailStatus(401) + await Promise.all([f.account.getProfile(), f.account.getBalance()]) + expect(signedOut).toBe(1) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired' }) +}) + +it.each([403, 500])('retains the account grant after HTTP %s', async (status) => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(status) + expect(await f.account.getBalance()).toEqual({ status: 'failed' }) + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) +}) + +it('does not remove a replacement grant when an older request is rejected', async () => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(401) + f.holdDetails() + const pending = f.account.getBalance() + try { + await f.detailsStarted.promise + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'replacement-grant' }, + })) + } finally { + f.release.resolve(undefined) + } + expect(await pending).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'replacement-grant' } }) +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 4841809a90..c2ae5249f5 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md -README.md: 52bc8e15ee72282b79c0b8823b56476650cdf728 -README.zh.md: 427b4a22aaf1ed503d41e90d0db600d220ef931e +README.md: bfebbc1ca9fa7cdfedbfbef7b6ca5f696043752c +README.zh.md: dd1fe65c17a89a73132380d663743601dd0ec3d5 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 52bc8e15ee..bfebbc1ca9 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. +`AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. + ## Summary Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index 427b4a22aa..dd1fe65c17 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 + ## 概述 账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts index f59c959567..7bc5f624f9 100644 --- a/packages/credentials/deepseek-account/src/types.ts +++ b/packages/credentials/deepseek-account/src/types.ts @@ -21,6 +21,8 @@ export interface AccountLinks { /** Stored-account presence is not a claim that the server has validated its token. */ export interface AccountView { readonly status: 'signed-out' | 'credential-stored' + /** Server rejection cleared the local credential. */ + readonly signOutReason?: 'expired' readonly links: AccountLinks readonly attempt: SignInAttemptView | null } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f05c4ed4f6..53b23dd9f8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4130,7 +4130,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AccountView', - declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', + declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly signOutReason?: \'expired\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', }, { name: 'AccountWallet', From bc8e8005fc4d17a2ec4046c7103256ac43878c00 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:19:29 +0800 Subject: [PATCH 026/253] docs: refresh module graph for account provider dependencies --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 32 +++++++++++++++++--------------- docs/module-graph.zh.md | 32 +++++++++++++++++--------------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ae367c64a..19bba03247 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e -module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e +module-graph.md: 7ccaa6d9eb060fadf75f581c6837cb7d06bd9e61 +module-graph.zh.md: 0e2ff4f1ea5780f7adc11693d7a97c39b37b0c3b diff --git a/docs/module-graph.md b/docs/module-graph.md index bfe3e5faa7..7ccaa6d9eb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -460,8 +460,6 @@ flowchart TD pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_api_remotes --> pkg_scope pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment @@ -546,17 +544,6 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -609,6 +596,18 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout + pkg_llm_deepseek --> pkg_agent + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_account + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -631,6 +630,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -1475,7 +1477,6 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1515,7 +1516,6 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1530,10 +1530,12 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bfa0f83e14..0e2ff4f1ea 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -462,8 +462,6 @@ flowchart TD pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_api_remotes --> pkg_scope pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment @@ -548,17 +546,6 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -611,6 +598,18 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout + pkg_llm_deepseek --> pkg_agent + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_account + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -633,6 +632,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -1477,7 +1479,6 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1517,7 +1518,6 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1532,10 +1532,12 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | From 43f107d164ce69a24c98f65b8835ce43982f7647 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:27:38 +0800 Subject: [PATCH 027/253] fix(account): expire grants rejected by Platform response code --- .../deepseek-account-platform/README.i18n.yaml | 4 ++-- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/details.ts | 2 +- .../deepseek-account-platform/src/index.ts | 2 +- .../deepseek-account-platform/src/protocol.ts | 9 +++++++-- .../tests/account.spec.ts | 15 +++++++++++++++ .../tests/authorization-response.spec.ts | 18 ++++++++++++++++++ 8 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 4a5e95d0b8..1e0eb5ef40 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: 204689db355a988daa91f645c338421c50429193 -README.zh.md: 6ae778df799b6fff8e01bf46e284a537819b9412 +README.md: a7e9ef87ce59a01cef4a20b2d544a986d26a7d73 +README.zh.md: f64964af053f4ab601cdda3b90f4c15ffc360650 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 204689db35..a7e9ef87ce 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 6ae778df79..f64964af05 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index a7847d0ca8..a3638116b4 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -48,7 +48,7 @@ const queries: { [K in keyof AccountDetails]: { * @param signal - request and credential lifetime. * @param headers - validated deployment headers for the configured origin. * @returns sanitized query outcome; failure never becomes a zero balance. - * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401. + * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401 or response code 40003. */ export async function readAccountDetail(field: K, origin: string, token: string, signal: AbortSignal, headers: Record): Promise { diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index d0cc6f5d21..06896b701a 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -202,7 +202,7 @@ export class PlatformAccount extends DeepSeekAccount { { ...this.accountRequestHeaders, ...this.clientHeaders }) return this.detailsLifetime !== lifetime ? null : details } catch (_unauthorized) { - // readAccountDetail exposes only authenticated HTTP 401 failures. + // readAccountDetail exposes only authenticated credential rejection failures. if (this.detailsLifetime === lifetime) await this.expireCredential(stored.token, lifetime) return null } diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index 092518c4ad..c8e65107d8 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -7,7 +7,7 @@ export class PlatformAuthError extends Error { constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } } -/** An authenticated Platform request was rejected with HTTP 401. */ +/** An authenticated Platform request was rejected with HTTP 401 or code 40003. */ export class AccountUnauthorizedError extends PlatformAuthError { constructor() { super('expired') } } @@ -162,6 +162,10 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } stage = 'parse-json' const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + const authorization = z.object({ code: z.literal(40003) }).safeParse(payload) + if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) { + throw new AccountUnauthorizedError() + } const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) if (codes.success) console.info('[deepseek-account] response codes', { path, code: codes.data.code, bizCode: codes.data.data?.biz_code, @@ -182,7 +186,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } return parsed.data.data.biz_data } catch (error) { - console.info('[deepseek-account] response rejected', { path, stage, errorCode: 'protocol' }) + console.info('[deepseek-account] response rejected', { path, stage, + errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' }) if (error instanceof PlatformAuthError) throw error throw new PlatformAuthError('protocol') } finally { diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index a6e90657bd..805c8ed46d 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -49,6 +49,7 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailCode = 0 let detailStatus = 200 let profileFailed = false let summaryFailed = false @@ -84,6 +85,7 @@ async function fixture( detailsStarted.resolve(undefined) if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise if (status !== 200) { res.writeHead(status).end(); return } + if (detailCode !== 0) { res.end(JSON.stringify({ code: detailCode, data: null })); return } const value = req.url === '/auth-api/v0/users/current' ? { id: 'test-user', token: 'never-copy-response-token', ...contact, id_profile: { name: 'Test Account', picture: null } } @@ -159,6 +161,7 @@ async function fixture( ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, holdLogout: () => { logoutHold = true }, failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + detailCode: (code: number) => { detailCode = code }, detailStatus: (status: number) => { detailStatus = status }, failProfile: (failed: boolean) => { profileFailed = failed }, holdBalance: () => { balanceHold = true }, @@ -883,6 +886,18 @@ it.each(['profile', 'balance'] as const)('clears the rejected account grant afte expect(signedOut).toBe(1) }) +it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 200 with code 40003', async (field) => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailCode(40003) + expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() + expect(signedOut).toBe(1) +}) + it('coalesces simultaneous unauthorized profile and balance responses', async () => { const f = await fixture() await storeAccount(f) diff --git a/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts b/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts new file mode 100644 index 0000000000..f28fd25928 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts @@ -0,0 +1,18 @@ +import { expect, it, vi } from 'vitest' +import { AccountUnauthorizedError, requestAccount, requestPlatform } from '../src/protocol.ts' + +it.each([null, undefined])('recognizes invalid authorization without requiring business data (%s)', async (data) => { + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 40003, data }))) + try { + await expect(requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'fixture-token', new AbortController().signal, {})).rejects.toBeInstanceOf(AccountUnauthorizedError) + } finally { fetcher.mockRestore() } +}) + +it('does not classify an unauthenticated exchange failure as a rejected stored credential', async () => { + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 40003, data: null }))) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_exchange', {}, + new AbortController().signal, {})).rejects.toThrow('account: protocol') + } finally { fetcher.mockRestore() } +}) From 37d3a31e295bb93e4bf29e46b1b962c147075f95 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:26 +0800 Subject: [PATCH 028/253] fix(account): sign out on rejected inference tokens --- .../tests/platform-session.spec.ts | 1 + docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 7 ++ docs/subsystems/credentials.zh.md | 7 ++ .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/index.ts | 7 ++ .../tests/account.spec.ts | 36 ++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/index.ts | 6 + .../extensions/tool-cordis/src/api-catalog.ts | 6 + packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 119 ++++++++++-------- packages/llm/llm-deepseek/src/index.ts | 1 + packages/llm/llm-deepseek/src/transport.ts | 3 +- packages/llm/llm-deepseek/src/types.ts | 2 + .../llm/llm-deepseek/tests/adapter.spec.ts | 55 ++++++++ 22 files changed, 210 insertions(+), 68 deletions(-) diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index 3fee3c2d5e..7be4154979 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -20,6 +20,7 @@ class Account extends DeepSeekAccount { override async startSignIn() { return state } override async cancelSignIn() { return state } override async signOut() { return state } + override async rejectToken() {} override async resolveToken() { return undefined } override async getPlatformSession() { this.reading.resolve(undefined); return this.read() } override async *watch(signal: AbortSignal) { diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 186e3f2fb6..509edf1de2 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/credentials.md -credentials.md: bce883640da863b5d0cc7e5657ee2fcff9eec661 -credentials.zh.md: 58ed1e1d1df217f0ecdecc06a55e36447ad28d99 +credentials.md: f6d510d48ec92feb28cc3af8820a1e497fad2cdd +credentials.zh.md: 7040fe28cb930a0f2dce9a092124fa24e72cf767 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index bce883640d..f6d510d48e 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -320,6 +320,13 @@ abstract watch(signal: AbortSignal): AsyncIterable */ abstract resolveToken(url: string): Promise +/** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ +abstract rejectToken(token: string): Promise + /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 58ed1e1d1d..7040fe28cb 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -320,6 +320,13 @@ abstract watch(signal: AbortSignal): AsyncIterable */ abstract resolveToken(url: string): Promise +/** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ +abstract rejectToken(token: string): Promise + /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 1e0eb5ef40..69ffd59af5 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: a7e9ef87ce59a01cef4a20b2d544a986d26a7d73 -README.zh.md: f64964af053f4ab601cdda3b90f4c15ffc360650 +README.md: ecb4f9616bc9da0d25d5d8f70690efa983b35279 +README.zh.md: e384b982def75b07bb7290ca1bf70b1b4d144c70 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index a7e9ef87ce..ecb4f9616b 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. Host inference consumers can report a rejected request token through `rejectToken`; removal requires that it still matches the stored login. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index f64964af05..e384b982de 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。Host 推理调用方可以通过 `rejectToken` 报告被拒绝的请求 token;仅当它仍匹配当前登录凭据时才执行清除。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 06896b701a..3909be12e5 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -209,6 +209,13 @@ export class PlatformAccount extends DeepSeekAccount { } + override async rejectToken(token: string): Promise { + const lifetime = this.detailsLifetime + const stored = await this.readCurrentGrant(lifetime) + if (stored?.token !== token || this.detailsLifetime !== lifetime) return + await this.expireCredential(token, lifetime) + } + private async expireCredential(token: string, lifetime: AbortController): Promise { this.removing ??= (async () => { if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 805c8ed46d..5d80023af5 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -1346,3 +1346,39 @@ it('ignores a 401 whose response cleanup overlaps a credential replacement', asy .toMatchObject({ kind: 'grant', payload: { token: 'new-login' } }) } finally { cleanup.mockRestore(); fetchResponse.mockRestore() } }) + +it('expires an inference-rejected token without another Platform request', async () => { + const f = await fixture() + await storeAccount(f) + const published: string[] = [] + f.ctx.on('deepseek-account/signed-out', () => { published.push('signed-out') }) + await f.account.rejectToken('test-platform-grant') + const state = await f.account.getState() + expect({ status: state.status, reason: state.signOutReason, published, detailRequests: f.detailRequests }) + .toMatchInlineSnapshot(` + { + "detailRequests": [], + "published": [ + "signed-out", + ], + "reason": "expired", + "status": "signed-out", + } + `) +}) + +it('retains a replacement login when inference rejects the previous token', async () => { + const f = await fixture() + await storeAccount(f) + await f.account.rejectToken('previous-platform-grant') + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'test-platform-grant' } }) +}) + +it('ignores an inference rejection while already signed out', async () => { + const f = await fixture() + await f.account.rejectToken('previous-platform-grant') + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) + expect((await f.account.getState()).signOutReason).toBeUndefined() +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 0289b83b50..04ef86deeb 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md -README.md: 85b4c9a7eca12358b425ccf7db092ae71ec98465 -README.zh.md: f29d1becdb258bfd6bc3b833d6e723f1f9e0e8f1 +README.md: eabed7ba74cc9995a46e42eb13eb238a06ec0359 +README.zh.md: 0f48d6286e8eec9d375439e052272e10f2871806 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 85b4c9a7ec..eabed7ba74 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. +`rejectToken` accepts a Host inference request’s rejected token and removes only the matching current login; a late rejection cannot clear a replacement credential. + `AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. ## Summary diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index f29d1becdb..0f48d6286e 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`rejectToken` 接收 Host 推理请求被拒绝的 token,仅删除与它匹配的当前登录凭据;延迟返回的拒绝不能清除替换后的凭据。 + `AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 ## 概述 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 6ae86556c7..f876c749c8 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -75,6 +75,12 @@ export abstract class DeepSeekAccount extends Service { * @returns stored token, or undefined for other origins or a signed-out account. */ abstract resolveToken(url: string): Promise + /** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ + abstract rejectToken(token: string): Promise /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5c4b0b05d8..6de43328f2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -901,6 +901,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'url', description: 'actual request destination or API base URL.' }], returns: 'stored token, or undefined for other origins or a signed-out account.', }, + { + signature: 'abstract rejectToken(token: string): Promise', + description: 'Remove an inference-rejected token only while it still matches the stored login.', + parameters: [{ name: 'token', description: 'token captured by the rejected inference request.' }], + returns: 'after matching credentials are removed and expiry state is published.', + }, { signature: 'abstract getPlatformSession(): Promise', description: 'Read credentials for the configured Platform origin, bound to their issuing environment.', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index d43739e1f4..765dd5a54a 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 6a3e89788a5a23a56727fb41615898993939ef8c -README.zh.md: db364b968f33838d3f8360ec54b2cf929866be67 +README.md: 8019a8be1f0c8e1f00ae5573e350c3a7eaf1c721 +README.zh.md: 86855c49ebeb9fd3c3ca578d52eefe7b6e4c8f92 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6a3e89788a..8019a8be1f 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -86,7 +86,7 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r `deepseek-official` resolves only its configured API-key reference. `deepseek-account` resolves only the stored grant from the [account provider](../../credentials/deepseek-account-platform/README.md), whose allowed `inferenceOrigin` defaults to `https://api.deepseek.com`. Neither route falls back to the other. Signing out removes the account grant and preserves API keys. -Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. +Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 with the provider message `Authentication Fails (invalid dsh token)` becomes `ACCOUNT_TOKEN_INVALID`. On the account route, it removes the request token only if it still matches the stored login, then publishes account expiry and UI sign-out. Generic authentication errors and API-key routes do not remove account credentials. ### Streaming with thinking and images diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index db364b968f..86855c49eb 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -86,7 +86,7 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 ` `deepseek-official` 仅解析配置的 API Key 引用。`deepseek-account` 仅解析[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)保存的授权,其允许的 `inferenceOrigin` 默认为 `https://api.deepseek.com`。两条路由均不回退到另一凭证。退出登录删除账号授权,保留 API Key。 -Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。 +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。HTTP 401 且提供方消息为 `Authentication Fails (invalid dsh token)` 时归类为 `ACCOUNT_TOKEN_INVALID`。账号路由仅在请求 token 仍匹配已存登录凭据时删除它,并发布账号过期状态和 UI 退登通知。普通鉴权错误和 API Key 路由不会删除账号凭据。 ### 带 thinking 与图片的流式调用 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 03aa7b605b..41a2cf4a96 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -87,63 +87,72 @@ export class DeepSeekAdapter extends LlmAdapter { ) const key = await this.dependencies.resolveApiKey(connection) const accountToken = this.dependencies.accountCredential === true ? key : undefined - const files = new RequestFiles(this.files, { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, - }, - connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) - let inline = false - while (true) { - signal.throwIfAborted() - files.beginAttempt() - let fileIds: Awaited> | undefined - if (!inline) { - try { - fileIds = await prepareFileIds(messages, versions, files) - } catch (error) { - if (!(error instanceof FileResolutionFailure)) throw error - inline = true - continue + try { + const files = new RequestFiles(this.files, { + baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, + }, + connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) + let inline = false + while (true) { + signal.throwIfAborted() + files.beginAttempt() + let fileIds: Awaited> | undefined + if (!inline) { + try { + fileIds = await prepareFileIds(messages, versions, files) + } catch (error) { + if (!(error instanceof FileResolutionFailure)) throw error + inline = true + continue + } + } + const history = inline ? inlineImages(messages, versions, connection) : messages + const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { + this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) + }, fileIds) + const extensions = await prepareRequestExtensions(body as Readonly>, { + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }, this.dependencies.prepareExtensions) + signal.throwIfAborted() + const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { + method: 'POST', signal, body: extensions.payload, redirect: 'error', + headers: { + ...attributionHeaders(), + 'content-type': 'application/json', 'accept': 'text/event-stream', + ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + 'anthropic-version': '2023-06-01', + ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, + 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), + ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, + ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, + }, + }) + if (!response.ok) { + const text = await response.text() + let raw: unknown + try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { + // HTTP status is authoritative when a gateway does not return JSON. + } + const detail = providerErrorDetail(raw) + if (await files.retry(detail)) continue + const failure = providerError(raw, response.status, response.headers) + const message = files.errorMessage(response.status, failure.message, detail) + throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + } + await extensions.accept() + if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') + yield* translate(parseSse(response.body, activity), options.model) + return + } + } catch (error) { + if (accountToken !== undefined && error instanceof LlmError && error.code === 'ACCOUNT_TOKEN_INVALID') { + try { await this.dependencies.onInvalidAccountToken?.(accountToken) } catch (_credentialRemovalFailed) { + // Credential storage failure cannot replace the inference failure. } } - const history = inline ? inlineImages(messages, versions, connection) : messages - const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { - this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) - }, fileIds) - const extensions = await prepareRequestExtensions(body as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.dependencies.prepareExtensions) - signal.throwIfAborted() - const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { - method: 'POST', signal, body: extensions.payload, redirect: 'error', - headers: { - ...attributionHeaders(), - 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, - 'anthropic-version': '2023-06-01', - ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, - 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), - ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, - ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, - }, - }) - if (!response.ok) { - const text = await response.text() - let raw: unknown - try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { - // HTTP status is authoritative when a gateway does not return JSON. - } - const detail = providerErrorDetail(raw) - if (await files.retry(detail)) continue - const failure = providerError(raw, response.status, response.headers) - const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) - } - await extensions.accept() - if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') - yield* translate(parseSse(response.body, activity), options.model) - return + throw error } } } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 366f184e03..34a2581ece 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -89,6 +89,7 @@ export function apply(ctx: Context, config: Config): void { const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ accountCredential, + ...accountCredential ? { onInvalidAccountToken: async (token: string) => { await ctx.get('deepseekAccount')?.rejectToken(token) } } : {}, options, onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts index 935e8855df..ddae610178 100644 --- a/packages/llm/llm-deepseek/src/transport.ts +++ b/packages/llm/llm-deepseek/src/transport.ts @@ -26,7 +26,8 @@ export function providerError(raw: unknown, status: number | undefined, headers? const type = typeof error.type === 'string' ? error.type : '' const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}` let code: string - if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' + if (status === 401 && message === 'Authentication Fails (invalid dsh token)') code = 'ACCOUNT_TOKEN_INVALID' + else if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' else if (isQuotaExceededError(detail) || status === 402) code = 'QUOTA' else if (status === 429 || type === 'rate_limit_error') code = 'RATE_LIMIT' else if (isContextWindowExceededError(detail)) code = 'CONTEXT_WINDOW_EXCEEDED' diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 25bf19636b..8beb5030d5 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -86,6 +86,8 @@ export interface DeepSeekConnectionOptions { export interface DeepSeekAdapterOptions { /** Report unusable native Messages replay metadata without exposing content or signatures. */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void + /** Remove a server-rejected account token if it still matches the current login; failures preserve the inference error. */ + onInvalidAccountToken?: (token: string) => Promise /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 088985a3ad..b051aedda6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -277,6 +277,61 @@ describe('Cordis provider composition', () => { return { ctx, http } } + it.each(['deepseek-account', 'deepseek-official'])('reports the rejected account token only for %s', async (provider) => { + const { ctx } = await boot((response) => { + response.writeHead(401, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'Authentication Fails (invalid dsh token)' } })) + }) + const rejectToken = vi.fn(async (_token: string) => {}) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + }) + expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) + }) + + it('finishes the active account turn when rejected credentials publish sign-out', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } })) + }) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: async (_token: string): Promise => { ctx.emit('deepseek-account/signed-out') } } as DeepSeekAccount) + const agent = await ctx.agentLoop.create(SessionId('inference-account-expiry'), { provider: 'deepseek-account', model: MODEL }) + agent.followup(user('hello')) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ + reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } }, + }) + }) + + it('preserves the inference error when rejected credential removal fails', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } })) + }) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: async (_token: string): Promise => { throw new Error('credential storage unavailable') } } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider: 'deepseek-account' })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + }) + }) + + it('does not remove account credentials for a generic authentication error', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } })) + }) + const rejectToken = vi.fn(async (_token: string) => {}) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider: 'deepseek-account' })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'AUTH' } }, + }) + expect(rejectToken).not.toHaveBeenCalled() + }) + it.each([ { model: MODEL, inHistory: false }, { model: MODEL, inHistory: true }, From 64154d2f68f4c43e7a7c0a2752bfdc063abd4dc1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:49:54 +0800 Subject: [PATCH 029/253] test(models): declare available fixture models and update selection flows --- apps/web/tests/deepseek-messages-settings.e2e.ts | 9 ++++----- apps/web/tests/github-ready-review.e2e.ts | 1 + apps/web/tests/onboarding-deepseek-config.e2e.ts | 5 ++--- apps/web/tests/reasoning-preview.e2e.ts | 1 + apps/web/tests/schedule-after.e2e.ts | 3 +++ apps/web/tests/shipped-composition.e2e.ts | 2 ++ apps/web/tests/streaming-fence-highlight.e2e.ts | 1 + apps/web/tests/support.ts | 6 ++++-- packages/sdk/server/tests/server.spec.ts | 5 ++++- 9 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 8e5f2a9617..93aa400f57 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -84,17 +84,16 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps a saved DeepSeek selection available after editing provider settings', async () => { + it('selects an available DeepSeek model after provider settings remove the default', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() - await expect.poll(() => input.isEnabled()).toBe(true) - await page.getByRole('button', { name: /^选择模型/ }).click() - await page.getByRole('menuitem', { name: /模型/ }).click() + await expect.poll(() => input.getAttribute('contenteditable')).toBe('false') + await page.getByRole('button', { name: '请选择模型', exact: true }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() - await expect.poll(() => input.isEnabled()).toBe(true) + await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official') const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(settings).toContain('provider: deepseek-official') diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 1477e2ea25..8bca2a746a 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -35,6 +35,7 @@ const REPLY = 'Review complete: no actionable findings.' /** Deterministic model response for the webhook-created Session. */ class ReviewAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 30cae9db7b..d0e5ddd582 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -273,12 +273,11 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model // trigger — on the page; the scaffold boots without one. - await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e') + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false) - const modelTrigger = page.getByRole('button', { name: /^选择模型/ }) + const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0) await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 9571b51d1c..0d5911edde 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -14,6 +14,7 @@ const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url)) class PausedReasoningAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] } readonly stages = DELTAS.map(text => ({ text, arrived: Promise.withResolvers(), diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 5dd7bf2ccb..9ce1560f7b 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -92,6 +92,7 @@ function textResponse(text: string): StreamChunk[] { /** Deterministic model seam that turns one due reminder into ordinary assistant prose. */ class ReminderAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { @@ -102,6 +103,7 @@ class ReminderAdapter extends LlmAdapter { /** Deterministic model seam for one multi-record fixed-rate batch. */ class EveryReminderAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { @@ -137,6 +139,7 @@ function localAt(epoch: number, timeZone: string): LocalAt { /** Dynamic model seam proving request-local browser context becomes an explicit At selector. */ class BrowserZoneAtAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] selectedAt: LocalAt | undefined scheduledAt: string | undefined diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index d1e6ecd83a..f6727eecaa 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -94,6 +94,7 @@ function textChunks(text: string): StreamChunk[] { /** Scripted same-route main model and reviewer for the shipped Auto pipeline. */ class ShippedAutoAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: AUTO_MODEL, name: AUTO_MODEL }] } readonly requests: GenerateOptions[] = [] constructor(private readonly targetPath: string) { @@ -196,6 +197,7 @@ function topLevelText(options: GenerateOptions): string { /** Same-route scripts for real one-shot, continuable, and cold-resumed children. */ class ShippedChildAutoAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: AUTO_MODEL, name: AUTO_MODEL }] } readonly requests: GenerateOptions[] = [] readonly reviews: ChildReviewObservation[] = [] private readonly children = new Map() diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 6d65cdca0d..bff5ff0f7e 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -30,6 +30,7 @@ const REPLY = `${OPEN_REPLY}\n\`\`\`` /** Deterministic model response held after each visible fence-growth frame. */ class StreamingFenceAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } private resolveFirstPaused!: () => void private resolveFirstContinuation!: () => void private resolveSecondPaused!: () => void diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9945da66a4..83e5d25253 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -153,8 +153,9 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo * @param page - the browser page under test. * @param root - workspace parent directory. * @param name - directory created under `root` and connected. + * @param modelAvailable - require an editable composer when the selected model is available. */ -export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { +export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise { mkdirSync(join(root, name), { recursive: true }) await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) @@ -164,7 +165,8 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') + const editable = modelAvailable ? '[contenteditable="true"]' : '' + await page.locator(`[data-composer-input]${editable}[data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]`) .waitFor({ timeout: 15_000 }) } diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 3448c5dc87..eb74c12243 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -922,7 +922,10 @@ describe('HarnessSdkJsonRpcServer', () => { await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' })) .rejects.toThrow('no adapter registered for provider "private"') - expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.get('llm')?.listProviders()).toEqual([ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'deepseek-account', name: 'DeepSeek Account' }, + ]) await server.shutdown() } finally { await ctx.fiber.dispose() From 3e4119ebdb331ebca0a78676a3967ed3db0f3734 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:12:01 +0800 Subject: [PATCH 030/253] fix(account): expire account credentials on inference HTTP 401 --- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 3 ++- packages/llm/llm-deepseek/src/transport.ts | 3 +-- packages/llm/llm-deepseek/tests/adapter.spec.ts | 15 ++++++++++----- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 765dd5a54a..7381689090 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 8019a8be1f0c8e1f00ae5573e350c3a7eaf1c721 -README.zh.md: 86855c49ebeb9fd3c3ca578d52eefe7b6e4c8f92 +README.md: 8d8e28faddf91026c249dbbfb0cfc183c815ae9d +README.zh.md: cee5d30a17a39d9e0613747db4b1a0035bef9c53 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8019a8be1f..8d8e28fadd 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -86,7 +86,7 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r `deepseek-official` resolves only its configured API-key reference. `deepseek-account` resolves only the stored grant from the [account provider](../../credentials/deepseek-account-platform/README.md), whose allowed `inferenceOrigin` defaults to `https://api.deepseek.com`. Neither route falls back to the other. Signing out removes the account grant and preserves API keys. -Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 with the provider message `Authentication Fails (invalid dsh token)` becomes `ACCOUNT_TOKEN_INVALID`. On the account route, it removes the request token only if it still matches the stored login, then publishes account expiry and UI sign-out. Generic authentication errors and API-key routes do not remove account credentials. +Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 on a request authenticated with an account token becomes `ACCOUNT_TOKEN_INVALID`, regardless of the response body. It removes that request token only if it still matches the stored login, then publishes account expiry and UI sign-out. API-key routes and other HTTP statuses do not remove account credentials. ### Streaming with thinking and images diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 86855c49eb..cee5d30a17 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -86,7 +86,7 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 ` `deepseek-official` 仅解析配置的 API Key 引用。`deepseek-account` 仅解析[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)保存的授权,其允许的 `inferenceOrigin` 默认为 `https://api.deepseek.com`。两条路由均不回退到另一凭证。退出登录删除账号授权,保留 API Key。 -Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。HTTP 401 且提供方消息为 `Authentication Fails (invalid dsh token)` 时归类为 `ACCOUNT_TOKEN_INVALID`。账号路由仅在请求 token 仍匹配已存登录凭据时删除它,并发布账号过期状态和 UI 退登通知。普通鉴权错误和 API Key 路由不会删除账号凭据。 +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。使用账号 token 鉴权的请求返回 HTTP 401 时归类为 `ACCOUNT_TOKEN_INVALID`,不依赖响应体内容。仅当该请求的 token 仍匹配已存登录凭据时才删除它,并发布账号过期状态和 UI 退登通知。API Key 路由和其他 HTTP 状态码不会删除账号凭据。 ### 带 thinking 与图片的流式调用 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 41a2cf4a96..2010a72dcf 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -139,7 +139,8 @@ export class DeepSeekAdapter extends LlmAdapter { if (await files.retry(detail)) continue const failure = providerError(raw, response.status, response.headers) const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + const code = accountToken !== undefined && response.status === 401 ? 'ACCOUNT_TOKEN_INVALID' : failure.code + throw new LlmError(message, code, { ...failure.failure, cause: new Error(text) }) } await extensions.accept() if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts index ddae610178..935e8855df 100644 --- a/packages/llm/llm-deepseek/src/transport.ts +++ b/packages/llm/llm-deepseek/src/transport.ts @@ -26,8 +26,7 @@ export function providerError(raw: unknown, status: number | undefined, headers? const type = typeof error.type === 'string' ? error.type : '' const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}` let code: string - if (status === 401 && message === 'Authentication Fails (invalid dsh token)') code = 'ACCOUNT_TOKEN_INVALID' - else if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' + if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' else if (isQuotaExceededError(detail) || status === 402) code = 'QUOTA' else if (status === 429 || type === 'rate_limit_error') code = 'RATE_LIMIT' else if (isContextWindowExceededError(detail)) code = 'CONTEXT_WINDOW_EXCEEDED' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index b051aedda6..c03a5f6d65 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -277,16 +277,21 @@ describe('Cordis provider composition', () => { return { ctx, http } } - it.each(['deepseek-account', 'deepseek-official'])('reports the rejected account token only for %s', async (provider) => { + it.each(['deepseek-account', 'deepseek-official'].flatMap(provider => [ + { provider, body: JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } }) }, + { provider, body: JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } }) }, + { provider, body: 'Unauthorized' }, + { provider, body: '' }, + ]))('handles $provider HTTP 401 independently of the response body ($body)', async ({ provider, body }) => { const { ctx } = await boot((response) => { response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'Authentication Fails (invalid dsh token)' } })) + response.end(body) }) const rejectToken = vi.fn(async (_token: string) => {}) ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) expect((await chunks(ctx.llm.stream(options({ provider })))).at(-1)).toMatchObject({ - type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + type: 'finish', reason: { kind: 'error', failure: { code: provider === 'deepseek-account' ? 'ACCOUNT_TOKEN_INVALID' : 'AUTH' } }, }) expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) }) @@ -318,9 +323,9 @@ describe('Cordis provider composition', () => { }) }) - it('does not remove account credentials for a generic authentication error', async () => { + it('does not remove account credentials for HTTP 403', async () => { const { ctx } = await boot((response) => { - response.writeHead(401) + response.writeHead(403) response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } })) }) const rejectToken = vi.fn(async (_token: string) => {}) From f0f4bb73fccb4c01710e7ba5d36c6cf7c4882f32 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:58:55 +0800 Subject: [PATCH 031/253] fix(desktop): keep welcome back action on one line --- apps/desktop/renderer/welcome.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 3d5fc88460..068bcc36fd 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -192,6 +192,7 @@ body { top: calc(100% + 16px); left: 50%; transform: translateX(-50%); + white-space: nowrap; min-width: 58px; height: 28px; padding: 4px 10px; From 5a3c316401cd461f0234f64be39023702ccb4ea0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:21:12 +0800 Subject: [PATCH 032/253] fix(desktop): deliver welcome expiry notice once across windows --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/client/WelcomePage.tsx | 12 ++++-- apps/desktop/src/main.ts | 17 ++++++-- apps/desktop/src/preload-welcome.ts | 3 +- apps/desktop/src/welcome-api.ts | 6 +++ apps/desktop/src/welcome-window.ts | 5 ++- apps/desktop/tests/preload.e2e.ts | 3 ++ .../tests/welcome-renderer.client.spec.tsx | 39 +++++++++++++++++-- apps/desktop/tests/welcome-startup.spec.ts | 14 ++++++- apps/desktop/tests/welcome-window.spec.ts | 9 ++++- 12 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7dbf8777b4..a9b5df858a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: cb10794ae6e9c5cbdc9e0f58acdefe8a252e35e1 -README.zh.md: 778fe9c6bc8df8a727d2a03a818a44f6fbb2e0f9 +README.md: 4ad377430d679fc505c480f2766b474b060a9f60 +README.zh.md: a36ebf77394e016336018d02f38b6688b2936f7f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cb10794ae6..4ad377430d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -409,6 +409,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. +When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. + An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 778fe9c6bc..a36ebf7739 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -409,6 +409,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 +账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 + 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 054b9b11bf..b24618a0da 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -15,9 +15,7 @@ type Page = 'entry' | 'key' | 'account' */ export function Welcome({ api }: { api: WelcomeApi }) { const { messages: m } = api - const [expired, setExpired] = useState(false) const [expiryNotice, setExpiryNotice] = useState(false) - useEffect(() => { setExpiryNotice(expired) }, [expired]) const [page, setPage] = useState('entry') const pageRef = useRef('entry') const [attempt, setAttempt] = useState(null) @@ -53,9 +51,17 @@ export function Welcome({ api }: { api: WelcomeApi }) { mounted.current = true document.documentElement.lang = api.id document.title = m.welcomeTitle + const takeNotice = (): void => { + void api.takeNotice().then((notice) => { + if (mounted.current && notice === 'session-expired') setExpiryNotice(true) + }).catch((_closedChannel: unknown) => { + // A closed Welcome IPC channel must not interrupt the sign-in page. + }) + } + takeNotice() const stop = api.onAccountState((state) => { revision.current++ - setExpired(state.status === 'signed-out' && state.signOutReason === 'expired') + takeNotice() showAccount(state) }) return () => { mounted.current = false; stop() } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d9c55f44ae..45a3a4a8c4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -34,7 +34,7 @@ import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { openWelcomeWindow } from './welcome-window.ts' -import { WELCOME_IPC, needsWelcome } from './welcome-api.ts' +import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' import { DesktopUpdateJournal } from './update-journal.ts' import { DesktopUpdatePreparationError } from './update-error.ts' @@ -307,6 +307,7 @@ async function main(): Promise { let stopAccount: (() => void) | undefined let openedAttempt: string | undefined let returnedAttempt: string | undefined + let pendingWelcomeNotice: WelcomeNotice | undefined let previousAccountStatus: string | undefined const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) @@ -362,8 +363,13 @@ async function main(): Promise { } if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined) if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { - void readWelcomeState().then((value) => { - if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() } + void readWelcomeState().then(async (value) => { + if (needsWelcome(value) && !quitting) { + if (state.signOutReason === 'expired') pendingWelcomeNotice = 'session-expired' + enteredWorkspace = false + await showWelcome() + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) + } return undefined }).catch(() => undefined) } @@ -905,6 +911,11 @@ async function main(): Promise { } openingWelcome ??= (async () => { welcomeWindow = await openWelcomeWindow(locale, { + takeNotice: () => { + const notice = pendingWelcomeNotice + pendingWelcomeNotice = undefined + return Promise.resolve(notice) + }, startSignIn: async () => { if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') return welcomeBackend.account.start(locale.id) diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts index 490a894394..e9ec1aa910 100644 --- a/apps/desktop/src/preload-welcome.ts +++ b/apps/desktop/src/preload-welcome.ts @@ -3,13 +3,14 @@ import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-acc import { contextBridge, ipcRenderer } from 'electron' import { resolveDesktopLocale } from './locale.ts' -import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts' +import { WELCOME_IPC, type WelcomeApi, type WelcomeNotice, type WelcomeSaveResult } from './welcome-api.ts' const prefix = '--dsh-welcome-locale=' const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length) if (locale === undefined) throw new Error('desktop welcome: missing window locale') const api: WelcomeApi = { ...resolveDesktopLocale(locale), + takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise, startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise, cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise, copySignInLink: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.copyLink, id) as Promise, diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts index 36dd85f17a..015296776a 100644 --- a/apps/desktop/src/welcome-api.ts +++ b/apps/desktop/src/welcome-api.ts @@ -11,13 +11,19 @@ export const WELCOME_IPC = { cancel: 'dsh-welcome:cancel', copyLink: 'dsh-welcome:copy-link', state: 'dsh-welcome:state', + takeNotice: 'dsh-welcome:take-notice', } as const /** Credential writes return a safe outcome without exposing Host diagnostics. */ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false } +/** One-time notification retained by the main process until Welcome receives it. */ +export type WelcomeNotice = 'session-expired' + /** Host-owned operations used by the welcome window. */ export interface WelcomeOperations { + /** @returns the pending notification, clearing it before another renderer can receive it. */ + takeNotice(): Promise /** @returns account state after starting a login attempt. */ startSignIn(): Promise /** @param id - attempt to cancel. @returns the settled state. */ diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts index 0edda21454..a32e1f1c56 100644 --- a/apps/desktop/src/welcome-window.ts +++ b/apps/desktop/src/welcome-window.ts @@ -63,7 +63,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco const disposeHandlers = (): void => { if (!active) return active = false - for (const channel of [WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink]) { + for (const channel of [ + WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink, + ]) { ipcMain.removeHandler(channel) } disposeActiveHandlers = undefined @@ -74,6 +76,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco throw new Error('desktop welcome: rejected action from an unowned frame') } } + ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() }) ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { assertSender(event) if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false } diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts index 1022b3a8b8..d18f475bc1 100644 --- a/apps/desktop/tests/preload.e2e.ts +++ b/apps/desktop/tests/preload.e2e.ts @@ -80,6 +80,9 @@ describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop pr expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh') } else { expect(exposed.has('dshWelcome')).toBe(true) + const bridge = exposed.get('dshWelcome') as { takeNotice(): Promise } + void bridge.takeNotice() + expect(invoke).toHaveBeenCalledWith('dsh-welcome:take-notice') } }) }) diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx index 0778b59805..0d3586ecfe 100644 --- a/apps/desktop/tests/welcome-renderer.client.spec.tsx +++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx @@ -6,15 +6,16 @@ import { Welcome } from '../src/client/WelcomePage.tsx' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDesktopLocale } from '../src/locale.ts' import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' -import type { WelcomeSaveResult } from '../src/welcome-api.ts' +import type { WelcomeSaveResult, WelcomeNotice } from '../src/welcome-api.ts' const html = readFileSync(join(import.meta.dirname, '../renderer/welcome.html'), 'utf8') afterEach(cleanup) -function mount(language = 'zh-CN') { +function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined)) { cleanup() const stopAccount = vi.fn() const api = { + takeNotice, onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount), startSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), cancelSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), @@ -264,7 +265,9 @@ it.each(['copied', 'failed'] as const)('restores the copy action after %s feedba it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Welcome: %s', async (language) => { vi.useFakeTimers() try { - const view = mount(language) + const takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined).mockResolvedValueOnce('session-expired') + const view = mount(language, takeNotice) + await act(async () => {}) const publish = view.api.onAccountState.mock.calls[0]![0] const expired: AccountView = { status: 'signed-out', signOutReason: 'expired', attempt: null, links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } } @@ -277,5 +280,35 @@ it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Wel expect(screen.queryByRole('alert')).toBeNull() await act(async () => { publish(expired) }) expect(screen.queryByRole('alert')).toBeNull() + view.unmount() + mount(language, takeNotice) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() } finally { cleanup(); vi.useRealTimers() } }) + +it('does not infer a notification from a retained expired account snapshot', async () => { + const view = mount() + await act(async () => { + view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', signOutReason: 'expired', attempt: null, + links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } }) + }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it('keeps the entry usable when notification IPC fails', async () => { + const view = mount('en', vi.fn<() => Promise>().mockRejectedValue(new Error('closed'))) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(view.button('#api-key')) + expect(view.input.closest('[hidden]')).toBeNull() +}) + +it('ignores a notification received after its renderer unmounts', async () => { + const pending = Promise.withResolvers() + const view = mount('en', vi.fn<() => Promise>().mockReturnValue(pending.promise)) + view.unmount() + mount('en') + await act(async () => { pending.resolve('session-expired') }) + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 20a5a441f4..737a3167be 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -131,7 +131,8 @@ vi.mock('../src/welcome-window.ts', () => ({ state.welcomeLocale = locale state.operations = operations await state.beforeWelcome() - return { once: vi.fn(), close: state.closeWelcome } + return { once: vi.fn(), close: state.closeWelcome, isDestroyed: () => false, + show: vi.fn(), focus: vi.fn(), webContents: { send: vi.fn() } } }, })) @@ -179,6 +180,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho expect(state.showWorkspace).not.toHaveBeenCalled() state.loadWorkspace.mockClear() expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' }) + expect(await state.operations!.takeNotice()).toBeUndefined() expect(state.dialogLocale!().id).toBe('zh-CN') const attemptId = 'login' as NonNullable['id'] const account: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, @@ -228,9 +230,19 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.advanceTimersByTimeAsync(0) expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + expect(await state.operations!.takeNotice()).toBeUndefined() state.hasApiKey = false state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + expect(await state.operations!.takeNotice()).toBe('session-expired') + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() }) diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts index b357f8dcb5..5ab4a1c031 100644 --- a/apps/desktop/tests/welcome-window.spec.ts +++ b/apps/desktop/tests/welcome-window.spec.ts @@ -40,6 +40,7 @@ function createWindow() { beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() }) const operations = { + takeNotice: async () => undefined, startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), copySignInLink: async () => undefined, @@ -115,8 +116,14 @@ describe('desktop welcome window', () => { const saveApiKey = vi.fn(operations.saveApiKey) const skip = vi.fn(operations.skip) const copySignInLink = vi.fn(operations.copySignInLink) - await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink }) + const takeNotice = vi.fn(async () => 'session-expired' as const) + await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink, takeNotice }) const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const take = electron.handlers.get(WELCOME_IPC.takeNotice)! + await expect(take({ ...own, senderFrame: {} })).rejects.toThrow('unowned frame') + expect(takeNotice).not.toHaveBeenCalled() + expect(await take(own)).toBe('session-expired') + expect(takeNotice).toHaveBeenCalledOnce() const save = electron.handlers.get(WELCOME_IPC.saveApiKey)! await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') await expect(save({ ...own, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') From 06717aebbb79d78f69e2511eb63dcac26d49abe0 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 22 Sep 2026 20:11:51 +0800 Subject: [PATCH 033/253] build(desktop): bundle the main process after the workspace tsdown pass The root tsdown workspace pass builds every member concurrently, so the Desktop main bundle could resolve its inlined workspace devDependencies (dsh-home-paths, dsh-app-boot, dsh-deepseek-account) before their lib/ output existed. rolldown then kept them as external imports, which the asar does not ship, and the packaged application failed at launch with ERR_MODULE_NOT_FOUND on a clean checkout; stale lib/ output on long-lived checkouts hid the defect. apps/desktop leaves the root workspace list and build:lib:host bundles it after the Host pass. Every Desktop bundle now runs a rolldown plugin that fails the build when a chunk imports a bare specifier the packaged application cannot resolve: electron and the manifest dependencies for the main bundle, electron alone for sandboxed preloads. --- ...in-bundle-after-workspace-tsdown.i18n.yaml | 6 ++ ...ktop-main-bundle-after-workspace-tsdown.md | 29 +++++++ ...p-main-bundle-after-workspace-tsdown.zh.md | 29 +++++++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 4 + apps/desktop/package.json | 3 +- .../scripts/desktop-bundle-imports.d.mts | 24 ++++++ .../scripts/desktop-bundle-imports.mjs | 66 ++++++++++++++++ .../tests/desktop-bundle-imports.spec.ts | 78 +++++++++++++++++++ apps/desktop/tsdown.config.ts | 15 ++++ docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 3 +- docs/development.zh.md | 3 +- package.json | 2 +- tsdown.config.ts | 7 +- 19 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md create mode 100644 apps/desktop/scripts/desktop-bundle-imports.d.mts create mode 100644 apps/desktop/scripts/desktop-bundle-imports.mjs create mode 100644 apps/desktop/tests/desktop-bundle-imports.spec.ts diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml new file mode 100644 index 0000000000..d1ecdce5be --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md +2026-09-22-desktop-main-bundle-after-workspace-tsdown.md: 4aea81fd11873a53d54b16bc8fd4326d923d714b +2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md: 0f2fd83835254c241f9ccfffab93d5a0abd2c17b diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md new file mode 100644 index 0000000000..4aea81fd11 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md @@ -0,0 +1,29 @@ +# Agent Note: Bundle the Desktop main process after the workspace tsdown pass + +Status: implemented + +English | [中文](2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md) + +## Problem + +A macOS Desktop package built from a fresh worktree crashed at launch with `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`. The packaging log showed why: rolldown had reported `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`, and the same for `@deepseek-ai/dsh-app-boot` and `@deepseek-ai/dsh-deepseek-account`. The three packages are workspace devDependencies of `apps/desktop` that the main-process bundle must inline, because electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`. + +The root tsdown config builds every matched workspace member concurrently (`Promise.all` over the resolved configs in tsdown 0.22, with no dependency ordering), and `apps/desktop` was one of those members. Its bundle resolved the three packages through their `exports` to `lib/index.js` files that other concurrent builds had not written yet. On a checkout that had been built before, the stale `lib/` outputs existed and the bundle inlined them, which is why the defect surfaced only on a clean tree; `clean: false` in both configs kept it hidden. The broken bundle was also smaller (189 KB instead of 315 KB) and the packaging run passed every existing check, because the runtime payload smoke exercises the dsh runtime, not the Electron main process. + +## Decision + +`apps/desktop` leaves the root tsdown `workspace` list. The root `build:lib:host` script runs the Host tsdown pass and then `pnpm --filter @deepseek-ai/dsh-desktop run bundle`, so the main bundle resolves workspace `lib/` outputs that already exist. The package's own `build` script keeps the same `tsc -b` then `bundle` order for local use. A standalone run of the Desktop config produces byte-identical output to the former workspace-mode run: the config was already self-contained, and the root plugins contributed nothing to it. + +The Desktop config also asserts the invariant the crash violated. [`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) is a rolldown plugin on every Desktop bundle whose `generateBundle` hook fails the build when a chunk imports a bare specifier the packaged application cannot resolve: the main bundle may import `electron` and the manifest's `dependencies`, sandboxed preloads only `electron`, and Node builtins are always allowed. The check names the actual requirement rather than the symptom, so it also rejects a workspace package moved out of `dependencies` or a broken `exports` entry, and a rejected bundle writes no output. + +## Alternatives considered + +**Declare the three packages as runtime `dependencies` so electron-builder ships them in the asar.** This is how `@deepseek-ai/cordis` and `@deepseek-ai/dsh-api-gateway` reach the asar today, and it removes the ordering problem. `dsh-app-boot` would bring a large dependency tree into `app.asar/node_modules`, duplicating the copy already inside the packed dsh runtime and enlarging the payload the runtime file policy must screen. Inlining the main-process helpers was a deliberate choice; the fix keeps it. + +**Fail on rolldown's `UNRESOLVED_IMPORT` warning** through `inputOptions.onLog` or `failOnWarn`. It catches only the unresolved case and would not have covered a dependency misdeclared as a devDependency, which produces a resolved, inlined bundle when the output exists and an external import when it does not. The packaged-imports check covers both with one rule; a warning-based check remains an optional addition. + +**Order the workspace build by dependency graph.** tsdown offers no such ordering, and every other member either inlines nothing from the workspace or externalizes declared `dependencies`, which need no ordering. One extra step for the one package that needs it is smaller than a general scheduler. + +## Consequences + +A clean checkout, including CI and a fresh worktree, produces the same Desktop main bundle as a long-lived one. A missing or misdeclared runtime import now fails `pnpm run build` and every packaging command with the offending file and specifiers, before signing and notarization spend their time. The Host build gains one sequential step of about a second on a development machine. `docs/development.md` lists the desktop bundle step in the root build order, and the [Desktop README](../../../../apps/desktop/README.md#bundled-workspace-dependencies) owns the import rule. diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md new file mode 100644 index 0000000000..0f2fd83835 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note:Desktop 主进程在工作区 tsdown 阶段之后再打 bundle + +Status: implemented + +[English](2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) | 中文 + +## 问题 + +从全新 worktree 打出的 macOS Desktop 安装包启动即崩:`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`。打包日志给出了原因:rolldown 报告了 `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`,`@deepseek-ai/dsh-app-boot` 与 `@deepseek-ai/dsh-deepseek-account` 也是同样情况。这三个包是 `apps/desktop` 的工作区 devDependencies,主进程 bundle 必须把它们内联,因为 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`。 + +根 tsdown 配置并发构建所有匹配到的 workspace 成员(tsdown 0.22 对解析出的配置做 `Promise.all`,没有任何依赖排序),而 `apps/desktop` 是其中一员。它的 bundle 通过这三个包的 `exports` 解析到 `lib/index.js`,而其他并发构建此时还没有写出这些文件。在此前构建过的检出目录里,陈旧的 `lib/` 产物存在,bundle 会把它们内联,因此缺陷只在干净树上出现;两份配置里的 `clean: false` 一直掩盖着它。坏的 bundle 也更小(189 KB 而不是 315 KB),并且打包流程通过了所有已有检查,因为运行时载荷 smoke 检验的是 dsh 运行时,不是 Electron 主进程。 + +## 决策 + +`apps/desktop` 退出根 tsdown 的 `workspace` 列表。根 `build:lib:host` 脚本先跑 Host tsdown 阶段,再执行 `pnpm --filter @deepseek-ai/dsh-desktop run bundle`,让主进程 bundle 解析的是已经存在的工作区 `lib/` 产物。该包自己的 `build` 脚本为本地使用保留同样的先 `tsc -b` 再 `bundle` 的顺序。单独运行 Desktop 配置得到的产物与此前 workspace 模式下的产物逐字节一致:该配置本来就是自足的,根配置的插件没有为它贡献任何内容。 + +Desktop 配置同时断言这次崩溃所违反的不变量。[`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) 是挂在每个 Desktop bundle 上的 rolldown 插件,其 `generateBundle` 钩子在某个 chunk 导入了打包应用无法解析的裸说明符时让构建失败:主进程 bundle 可以导入 `electron` 与清单的 `dependencies`,沙箱 preload 只能导入 `electron`,Node 内置模块始终允许。这项检查陈述的是真正的要求而非症状,因此它同样拒绝被移出 `dependencies` 的工作区包或损坏的 `exports` 条目,且被拒绝的 bundle 不写出任何产物。 + +## 备选方案 + +**把这三个包声明为运行时 `dependencies`,让 electron-builder 装进 asar。** `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-api-gateway` 今天就是这样进入 asar 的,这也消除了顺序问题。但 `dsh-app-boot` 会把一棵很大的依赖树带进 `app.asar/node_modules`,与 packed dsh 运行时内已有的副本重复,并扩大运行时文件策略必须筛查的载荷。内联主进程辅助包是有意的选择;本修复保留它。 + +**通过 `inputOptions.onLog` 或 `failOnWarn` 让 rolldown 的 `UNRESOLVED_IMPORT` warning 直接失败。** 它只覆盖未解析这一种情况,覆盖不了被误声明为 devDependency 的依赖:产物存在时它会被解析并内联,不存在时才留成外部导入。打包导入检查用一条规则同时覆盖两者;基于 warning 的检查仍可作为可选补充。 + +**按依赖图给 workspace 构建排序。** tsdown 不提供这种排序,而其他每个成员要么不从工作区内联任何内容,要么把已声明的 `dependencies` 留成外部导入,都不需要排序。为唯一需要它的包多加一步,比通用调度器更小。 + +## 后果 + +干净的检出目录(包括 CI 与全新 worktree)产出的 Desktop 主进程 bundle 与长期使用的检出目录一致。缺失或误声明的运行时导入现在会让 `pnpm run build` 和每条打包命令在签名与公证耗时之前失败,并报出违规文件与说明符。Host 构建多出一个串行步骤,在开发机上约一秒。`docs/development.md` 在根构建顺序中列出了 desktop bundle 步骤,导入规则由 [Desktop README](../../../../apps/desktop/README.zh.md#bundled-workspace-dependencies) 负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..628c27e58b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: e534a9443be4fdc576878f39f132fff34973db2f +README.zh.md: 8123832ac97185539bff87257cdb8921e332d2d9 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..e534a9443b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -26,6 +26,8 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r ### Bundled workspace dependencies +electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron` and those `dependencies` as its only bare imports; sandboxed preloads import only `electron`. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose external imports the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [bundle-order decision](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. + Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..8123832ac9 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,8 +24,12 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 + + ### 内置工作区依赖 +electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron` 与这些 `dependencies`;沙箱 preload 只导入 `electron`。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何外部导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[bundle 顺序决策](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)记录了备选方案。 + Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..fa87ea8ad7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,8 @@ "main": "lib/main.js", "scripts": { "test:installer": "node scripts/test-windows-installer.mjs", - "build": "tsc -b && tsdown", + "build": "tsc -b && pnpm run bundle", + "bundle": "tsdown", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", diff --git a/apps/desktop/scripts/desktop-bundle-imports.d.mts b/apps/desktop/scripts/desktop-bundle-imports.d.mts new file mode 100644 index 0000000000..abb95a8474 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.d.mts @@ -0,0 +1,24 @@ +import type { Rolldown } from 'tsdown' + +/** + * Package name of a bare import specifier. + * @param specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns The package name without its subpath. + */ +export function importPackageName(specifier: string): string + +/** + * Bare imports the packaged application cannot resolve. + * @param imports - External specifiers a chunk imports, statically or dynamically. + * @param packaged - Package names available to the bundle at runtime. + * @returns Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports: readonly string[], packaged: ReadonlySet): string[] + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param packaged - Package names available to the bundle at runtime. + * @returns The plugin. + */ +export function packagedImportsPlugin(packaged: ReadonlySet): Rolldown.Plugin diff --git a/apps/desktop/scripts/desktop-bundle-imports.mjs b/apps/desktop/scripts/desktop-bundle-imports.mjs new file mode 100644 index 0000000000..16d8c45990 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.mjs @@ -0,0 +1,66 @@ +/** + * Keep every bare import a Desktop bundle leaves to the runtime resolvable + * inside the packaged application. + * + * electron-builder copies only the manifest's `dependencies` into + * `app.asar/node_modules`, so a bare import of anything else bundles without + * complaint and fails at launch with `ERR_MODULE_NOT_FOUND`. The main-process + * bundle inlines its workspace devDependencies; when one of their `lib/` + * outputs is missing at bundle time, rolldown reports `UNRESOLVED_IMPORT` as a + * warning and keeps the specifier as an external import, which is exactly the + * broken artifact. Sandboxed preloads may `require` only `electron`. This + * check fails the bundle instead of the installed application. + */ + +import { isBuiltin } from 'node:module' + +/** + * Package name of a bare import specifier. + * @param {string} specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns {string} The package name without its subpath. + */ +export function importPackageName(specifier) { + const segments = specifier.split('/') + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] +} + +/** + * Bare imports the packaged application cannot resolve. + * @param {readonly string[]} imports - External specifiers a chunk imports, statically or dynamically. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {string[]} Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports, packaged) { + const offending = new Set() + for (const specifier of imports) { + if (isBuiltin(specifier)) continue + if (packaged.has(importPackageName(specifier))) continue + offending.add(specifier) + } + return [...offending] +} + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {import('tsdown').Rolldown.Plugin} The plugin. + */ +export function packagedImportsPlugin(packaged) { + return { + name: 'desktop-packaged-imports', + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') continue + const external = [...output.imports, ...output.dynamicImports].filter(specifier => !(specifier in bundle)) + const offending = unpackagedImports(external, packaged) + if (offending.length === 0) continue + this.error( + `desktop bundle: ${output.fileName} imports ${offending.join(', ')}, which the packaged application does not ship. ` + + `A bare import must name electron or a package in this manifest's dependencies; anything else must be bundled, ` + + 'which requires its lib/ output to exist before this bundle runs (pnpm run build:lib:host).', + ) + } + }, + } +} diff --git a/apps/desktop/tests/desktop-bundle-imports.spec.ts b/apps/desktop/tests/desktop-bundle-imports.spec.ts new file mode 100644 index 0000000000..c37564020c --- /dev/null +++ b/apps/desktop/tests/desktop-bundle-imports.spec.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Rolldown } from 'tsdown' +import { afterAll, describe, expect, it } from 'vitest' +import { + importPackageName, + packagedImportsPlugin, + unpackagedImports, +} from '../scripts/desktop-bundle-imports.mjs' + +const PACKAGED = new Set(['electron', 'electron-updater', '@deepseek-ai/dsh-api-gateway']) + +describe('desktop bundle imports', () => { + it('names the package behind a bare specifier', () => { + expect(importPackageName('electron-updater/out/electronHttpExecutor.js')).toBe('electron-updater') + expect(importPackageName('@deepseek-ai/dsh-api-gateway/stream-protocol')).toBe('@deepseek-ai/dsh-api-gateway') + expect(importPackageName('ws')).toBe('ws') + }) + + it('accepts Node builtins and packaged dependencies, including their subpaths', () => { + expect(unpackagedImports([ + 'node:fs/promises', + 'crypto', + 'electron', + 'electron-updater/out/electronHttpExecutor.js', + '@deepseek-ai/dsh-api-gateway/stream-protocol', + ], PACKAGED)).toEqual([]) + }) + + it('reports each specifier the packaged application cannot resolve once, in import order', () => { + expect(unpackagedImports([ + '@deepseek-ai/dsh-home-paths', + 'ws', + '@deepseek-ai/dsh-home-paths', + 'electron', + ], PACKAGED)).toEqual(['@deepseek-ai/dsh-home-paths', 'ws']) + }) +}) + +describe('packaged imports plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-bundle-imports-')) + const entry = join(root, 'entry.js') + writeFileSync(entry, [ + 'import { app } from "electron";', + 'import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";', + 'export const home = resolveDshHome(app);', + ].join('\n')) + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function generate(packaged: ReadonlySet): Promise { + // Every import stays external, which is what an unresolved workspace lib/ output produces. + const bundle = await Rolldown.rolldown({ + input: entry, + external: () => true, + logLevel: 'silent', + plugins: [packagedImportsPlugin(packaged)], + }) + try { + return await bundle.generate({ format: 'esm' }) + } finally { + await bundle.close() + } + } + + it('fails the bundle that leaves an import the packaged application does not ship', async () => { + await expect(generate(new Set(['electron']))).rejects.toThrow( + /entry\.js imports @deepseek-ai\/dsh-home-paths, which the packaged application does not ship/u, + ) + }) + + it('passes the bundle whose external imports are all packaged', async () => { + const output = await generate(new Set(['electron', '@deepseek-ai/dsh-home-paths'])) + expect(output.output.map(chunk => chunk.fileName)).toEqual(['entry.js']) + }) +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6db212aee7..8af998c518 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -1,11 +1,25 @@ import { defineConfig } from 'tsdown' import { build } from 'vite' import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { packagedImportsPlugin } from './scripts/desktop-bundle-imports.mjs' + +// This config runs after the workspace tsdown pass, not inside it: the main bundle inlines +// workspace devDependencies from their lib/ output, which the concurrent workspace pass does +// not order ahead of this package (root package.json build:lib:host). +const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + dependencies: Record +} +/** electron-builder ships these next to the main bundle; Electron itself provides `electron`. */ +const mainProcessImports = new Set(['electron', ...Object.keys(manifest.dependencies)]) +/** Sandboxed preloads can require nothing but `electron`. */ +const preloadImports = new Set(['electron']) export default defineConfig([ { entry: ['lib/types/main.js'], + plugins: [packagedImportsPlugin(mainProcessImports)], onSuccess: async () => { await build({ configFile: false, @@ -49,6 +63,7 @@ export default defineConfig([ ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, + plugins: [packagedImportsPlugin(preloadImports)], outDir: 'lib', format: 'cjs' as const, codeSplitting: false, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index e11ee881f4..f515b0eff1 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae -api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 +api-gateway.md: d6a8444c9f18a7c3df9cd64e4a836d1831f8a0d8 +api-gateway.zh.md: 23c7c18d3d784fc2b6abe4c1df75e08447100b98 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 957f407758..d6a8444c9f 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -98,7 +98,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. -Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. +Both tsdown passes receive the same workspace match ([build order](development.md#typescript-project-layout)) and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. `api/remotes`, `api/gateway`, `api/session-controller`, and `api/workspace-controller` (plus `client/connection`) split TypeScript faces. `api/remotes`' Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference each split package's `tsconfig.host.json` or `tsconfig.client.json` respectively. `api-remotes`' `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. The Agent/Session lookup policy lives in `@deepseek-ai/dsh-api-session-controller`, not in `api-remotes`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 1062adcc89..23c7c18d3d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -98,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 -两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 +两次 tsdown 都接收同一组 workspace 匹配([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 `api/remotes`、`api/gateway`、`api/session-controller` 与 `api/workspace-controller`(外加 `client/connection`)都拆分 TypeScript face。`api/remotes` 的 Client project 依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用各拆分包自己的 `tsconfig.host.json` 或 `tsconfig.client.json`。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。Agent/Session lookup 策略位于 `@deepseek-ai/dsh-api-session-controller`,而非 `api-remotes`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fd6198a8ca..a14f6e2fd7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 -development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 +development.md: 812cabbe3e2af5965033ef0c73301e5cd28cba28 +development.zh.md: b45e8e3e7b3c5fc0ee05967273a1c73e407b32fc diff --git a/docs/development.md b/docs/development.md index 7e8d0ded70..812cabbe3e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,13 @@ The root build follows the generated dependency order: ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. +Both tsdown passes use the same workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. diff --git a/docs/development.zh.md b/docs/development.zh.md index 84d0d76d8c..b45e8e3e7b 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,12 +78,13 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 +两次 tsdown 都使用同一组 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员,因此主进程 bundle 要从工作区 devDependencies 的 `lib/` 产物内联它们的 `apps/desktop` 在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 diff --git a/package.json b/package.json index a7acc3ab12..d71d45d55f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", diff --git a/tsdown.config.ts b/tsdown.config.ts index 2f24d4910e..2435127329 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,14 +11,17 @@ function isBuildFaceClient(value: unknown): boolean { * The ordinary workspace build consumes JavaScript emitted by the Host * TypeScript project and runs Typert. The Client pass selects packages that * declare a browser bundle and lets their package-local configs emit both - * their Node loader entry and browser artifact. + * their Node loader entry and browser artifact. `apps/desktop` bundles after + * this pass (root package.json `build:lib:host`): its main bundle inlines + * workspace devDependencies from their lib/ output, and tsdown builds + * workspace members concurrently without ordering them. */ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: client ? ['vendor/*', 'packages/*/*', 'apps/cli'] - : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop', 'apps/desktop-host'], + : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop-host'], entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], From bca9cecc7d901de348b08ebf10b1012a527687c2 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:28:10 +0800 Subject: [PATCH 034/253] fix(client): keep pointer focus silent through descendant and editable rings Pointer suppression previously cleared `outline-color` on the focused element alone, so a ring painted by a descendant, a pseudo-element, or a `box-shadow` stayed visible, and editable text controls lost the focus feedback they need on click. Ring colour now flows through `--dsw-focus-ring-color`, which component outlines and focus-ring shadows read alongside the brand token. Every ring that inherits the variable is suppressed together, and `body :focus-visible:not(:read-write)` limits the suppression to rings while editable controls keep their own feedback. Keyboard modality returns on Tab, Home, End, PageUp, or PageDown, and on focus reaching a different control after a non-composing key. IME composition keys, refocusing the same control, and window blur leave rings silent. The desktop preload menubar suppresses its own ring inside its shadow root, where the document-level attribute does not reach. --- ...6-09-20-focus-ring-brand-primary.i18n.yaml | 4 +- .../2026-09-20-focus-ring-brand-primary.md | 30 +- .../2026-09-20-focus-ring-brand-primary.zh.md | 30 +- apps/desktop/src/preload-menu.ts | 3 +- apps/web/tests/focus-rings.e2e.ts | 357 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../src/client/AgentPresetSection.module.css | 6 +- .../ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 2 +- .../src/client/chat/TurnNavigator.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 +- .../src/client/ChangedFiles.module.css | 4 +- .../src/client/Deliverables.module.css | 2 +- .../src/client/DirectoryBrowser.module.css | 4 +- .../src/client/FeedbackDialog.module.css | 2 +- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PermissionSelect.module.css | 2 +- .../src/client/PlanModeControl.module.css | 2 +- .../ui-plan/src/client/PlanPreview.module.css | 4 +- .../src/client/PluginManagerPage.module.css | 10 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 + packages/client/ui-primitives/README.zh.md | 6 + .../ui-primitives/src/Checkbox.module.css | 2 +- .../src/ConnectionIndicator.module.css | 2 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/JsonTree.module.css | 4 +- .../src/RiskConfirmation.module.css | 2 +- .../ui-primitives/src/Switch.module.css | 2 +- .../ui-primitives/src/input-modality.ts | 47 ++- .../src/markdown/MarkdownText.module.css | 4 +- .../tests/input-modality.client.spec.ts | 87 ++++- .../src/client/ModelsSection.module.css | 2 +- .../PluginInventorySettingsTab.module.css | 8 +- .../src/client/PluginConfigForm.module.css | 2 +- .../client/PluginsSettingsSection.module.css | 2 +- .../src/client/fields.module.css | 2 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 6 +- packages/client/ui-theme/README.zh.md | 6 +- packages/client/ui-theme/src/styles/focus.css | 24 +- .../tests/focus-styles.client.spec.ts | 66 ++++ .../tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 24 +- .../src/client/TrajectoryTimeline.module.css | 4 +- .../src/client/TrajectoryToolbar.module.css | 6 +- .../src/client/WorkflowRunPanel.module.css | 6 +- .../client/rows/WorkspaceBrowser.module.css | 2 +- tsconfig.host.json | 1 + 50 files changed, 669 insertions(+), 141 deletions(-) create mode 100644 apps/web/tests/focus-rings.e2e.ts create mode 100644 packages/client/ui-theme/tests/focus-styles.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml index 6707b51680..f30d9be553 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md -2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e -2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 +2026-09-20-focus-ring-brand-primary.md: 288f3722428a5dd52822c3fecbbaaea5c7c6310f +2026-09-20-focus-ring-brand-primary.zh.md: 792374b92cbd056bf181b79f8b22e1aec572d3bb diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md index 3c6c041ac0..288f372242 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -6,28 +6,38 @@ English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) ## Problem -Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. +Different explicit focus colours and browser-default outlines do not provide a consistent keyboard-focus cue. Browser defaults can also depend on the operating-system accent. + +Focus feedback has separate interaction and geometry constraints. A browser's `:focus-visible` heuristic can reveal a pointer-focused button after a key that does not move focus, while editable text controls need focus feedback on click. Containers that clip their contents can hide an outer ring. ## Decision -Every keyboard focus ring uses `--dsw-alias-brand-primary`. +[The theme's focus stylesheet](../../../../packages/client/ui-theme/src/styles/focus.css) owns the shared ring colour and standard width. [Input modality](../../../../packages/client/ui-primitives/README.md#input-modality) distinguishes tooltip input from keyboard focus navigation. -Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. +**One colour.** Global and component `:focus-visible` outlines and focus-ring shadows read `--dsw-focus-ring-color`, falling back to `--dsw-alias-brand-primary`. The global fallback sets only outline colour; it does not create an outline where the component disables one. -The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. +**Context-owned geometry.** `--dsw-focus-ring-width` carries the standard 2px width. Dense tables and toolbars may keep 1px so the ring does not dominate compact content. Components own their offsets and use an inset ring where an outer ring would be clipped. + +**Pointer focus does not imply keyboard navigation.** In pointer modality, `:focus-visible:not(:read-write)` controls use a transparent ring colour. Descendant and pseudo-element rings inherit it. Only focus-ring paint is suppressed: selected-state borders and elevation shadows remain, and editable text controls keep their own focus feedback on click. DOM focus stays on the control. + +Tooltips follow the last input and treat any key as keyboard input. Rings instead resume keyboard styling on non-composing navigation keys, or on focus reaching a different control after a non-composing key. Refocusing the same control is not navigation; pointer input, composition keys, and window blur clear the pending key. ## Alternatives considered -**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. +**Rewrite only the component declarations.** Explicit rings would agree, but controls using a browser-default outline would retain its colour. Requiring every control to declare its own ring would also leave new controls inconsistent until their authors add that rule. -**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. +**Unify width and offset globally.** Positive offsets can be clipped by a scrolling ancestor, while a uniform thick ring can overwhelm dense content. Neither colour consistency nor input modality requires identical geometry. -**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. +**Suppress by moving focus instead of by style.** Calling `blur()` on a pointer-focused button would hide its ring but also remove the target for subsequent Enter or Space activation. -**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. +**Clear all shadows on pointer focus.** A `box-shadow` can contain elevation or selected-state paint as well as a focus ring. Suppressing its ring colour preserves those independent cues; exempting `:read-write` preserves text-entry feedback. + +**Replace every `outline: none` with a shared ring.** Some controls use an inset shadow or container-level focus treatment instead; some containers deliberately show no ring. Replacing those declarations would change their focus treatment, not merely its colour. + +**Give tooltips and rings the same modality rule.** A key can justify a tooltip on subsequent programmatic focus without justifying a ring on the same pointer-focused button. Keeping separate answers preserves both behaviours. ## Consequences -The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. +Focus rings share a theme colour while components retain their geometry and non-focus state cues. Ring visibility changes without changing the keyboard's active target. These rules do not guarantee identical browser rendering or eliminate component-specific clipping. -`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. +[Stylesheet-order checks](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts), [focus-style checks](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts), and [input-modality tests](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts) cover stylesheet declarations and modality transitions. Static declarations and synthetic events do not establish rendered ring visibility or cross-platform behaviour. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md index 541537c54e..792374b92c 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -6,28 +6,38 @@ Status: implemented ## 问题 -客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 +不同的显式焦点颜色与浏览器默认轮廓无法提供一致的键盘焦点提示。浏览器默认值还可能取决于操作系统强调色。 + +焦点反馈另有交互与几何约束。浏览器的 `:focus-visible` 启发式可能在不移动焦点的按键之后显出指针聚焦按钮的环,而可编辑文本控件在点击时需要焦点反馈。裁切内容的容器可能隐藏外扩环。 ## 决策 -所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 +[主题焦点样式表](../../../../packages/client/ui-theme/src/styles/focus.css) 拥有共享焦点环颜色与标准宽度。[输入模态](../../../../packages/client/ui-primitives/README.zh.md#input-modality) 区分 tooltip 输入与键盘焦点导航。 -实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 +**一个颜色。** 全局与组件的 `:focus-visible` 轮廓和焦点环阴影读取 `--dsw-focus-ring-color`,回退到 `--dsw-alias-brand-primary`。全局兜底只设置轮廓颜色;组件禁用轮廓时,它不会创建轮廓。 -全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 +**场景决定几何。** `--dsw-focus-ring-width` 提供标准的 2px 宽度。密集表格与工具栏可以保留 1px,避免焦点环压过紧凑内容。组件拥有自己的 offset,并在外扩环会被裁切时使用内嵌环。 + +**指针焦点不等于键盘导航。** 指针模态下,`:focus-visible:not(:read-write)` 控件使用透明的焦点环颜色。后代与伪元素上的环继承该值。只抑制焦点环绘制:选中态边框与 elevation 阴影保留,可编辑文本控件在点击时保留自身焦点反馈。DOM 焦点仍留在控件上。 + +Tooltip 跟随最近一次输入,将任意按键视为键盘输入。焦点环则在非组合输入的导航键到达时,或非组合输入按键之后焦点到达不同控件时,恢复键盘样式。重新聚焦同一控件不算导航;指针输入、组合输入按键和 window blur 会清除待处理按键。 ## 考虑过的其他做法 -**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 +**只重写组件声明。** 显式焦点环会一致,但使用浏览器默认轮廓的控件仍会保留其颜色。要求每个控件自行声明焦点环,也会让新控件在作者补上规则之前保持不一致。 -**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 +**全局统一宽度与 offset。** 正 offset 可能被滚动祖先裁切,统一的粗环则可能压过密集内容。颜色一致性和输入模态都不要求相同的几何。 -**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 +**用移动焦点取代样式抑制。** 对指针聚焦的按钮调用 `blur()` 可以隐藏焦点环,但也会移除后续 Enter 或 Space 激活的目标。 -**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 +**指针聚焦时清除所有阴影。** 一个 `box-shadow` 除了焦点环,还可能包含 elevation 或选中态绘制。抑制其焦点环颜色可保留这些独立提示;豁免 `:read-write` 可保留文本输入反馈。 + +**把所有 `outline: none` 改为共享焦点环。** 一些控件改用内嵌阴影或容器级焦点处理;一些容器则刻意不显示焦点环。替换这些声明会改变焦点处理方式,而不只是颜色。 + +**让 tooltip 与焦点环共用同一条模态规则。** 按键可以让后续程序化聚焦显示 tooltip,却不一定需要在同一个指针聚焦按钮上显示焦点环。分别记录两个答案可保留两种行为。 ## 影响 -焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 +焦点环共享主题颜色,组件保留自己的几何与非焦点状态提示。焦点环可见性的变化不改变键盘的活动目标。这些规则不保证浏览器渲染完全相同,也不会消除组件特有的裁切。 -`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 +[样式表顺序检查](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts)、[焦点样式检查](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts)与[输入模态测试](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts)覆盖样式声明和模态转换。静态声明与合成事件不能确认焦点环的实际渲染可见性或跨平台行为。 diff --git a/apps/desktop/src/preload-menu.ts b/apps/desktop/src/preload-menu.ts index aa2f54a423..d913f1ed3e 100644 --- a/apps/desktop/src/preload-menu.ts +++ b/apps/desktop/src/preload-menu.ts @@ -22,7 +22,8 @@ export function installWindowsMenu(): { update(): void; dispose(): void } { font: inherit; font-size: 14px; cursor: default; } button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } - button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; } + button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } + :host-context(html[data-input-modality='pointer']) button:focus-visible { outline-color: transparent; } ` const bar = document.createElement('div') bar.setAttribute('role', 'menubar') diff --git a/apps/web/tests/focus-rings.e2e.ts b/apps/web/tests/focus-rings.e2e.ts new file mode 100644 index 0000000000..49a17c6b2e --- /dev/null +++ b/apps/web/tests/focus-rings.e2e.ts @@ -0,0 +1,357 @@ +/** Real assembled UI plus source-compiled browser regressions for focus-ring paint. */ +import { join } from 'node:path' +import { build } from 'vite' +import { chromium, type Locator, type Page } from 'playwright' +import { beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, REPO_ROOT } from './support.ts' + +/** Read rendered paint and browser focus, not just the modality publisher's attribute. */ +async function paint(target: Locator) { + return target.evaluate((element) => { + const style = getComputedStyle(element) + const resolveColor = (value: string): string => { + const probe = document.createElement('span') + probe.style.color = value + document.body.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + } + return { + active: document.activeElement === element, + focusVisible: element.matches(':focus-visible'), + modality: document.documentElement.getAttribute('data-input-modality'), + brand: resolveColor(style.getPropertyValue('--dsw-alias-brand-primary').trim()), + hover: resolveColor(style.getPropertyValue('--dsw-alias-interactive-bg-hover').trim()), + outline: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + shadow: style.boxShadow, + background: style.backgroundColor, + } + }) +} + +async function expectSilent(target: Locator): Promise { + const state = await paint(target) + expect(state.active).toBe(true) + expect(state.modality).toBe('pointer') + expect(state.brand).toMatch(/^rgb/) + expect(state.outlineStyle === 'none' || state.outline === 'rgba(0, 0, 0, 0)').toBe(true) + expect(state.shadow).not.toContain(state.brand) +} + +async function expectBrand(target: Locator, kind: 'outline' | 'shadow'): Promise { + await expect.poll(async () => { + const state = await paint(target) + return { + active: state.active, + visible: state.focusVisible, + modality: state.modality, + painted: kind === 'shadow' + ? state.shadow.includes(state.brand) + : state.outline === state.brand && state.outlineStyle === 'solid' && Number.parseFloat(state.outlineWidth) > 0, + } + }).toEqual({ active: true, visible: true, modality: 'keyboard', painted: true }) +} + +it('assembled app: pointer keys stay silent; keyboard navigation and menu activation retain feedback', async () => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch({ headless: true }) + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const trigger = page.getByRole('button', { name: /^Access mode, current:/ }) + + await trigger.click() + await page.getByRole('menu').waitFor() + await expectSilent(trigger) + await page.keyboard.press('Shift') + await expectSilent(trigger) + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'hidden' }) + await expectSilent(trigger) + + for (const key of ['Home', 'End']) { + await trigger.click() + await page.keyboard.press('Escape') + await expectSilent(trigger) + await page.keyboard.press(key) + await expectBrand(trigger, 'shadow') + } + + await trigger.click() + await page.keyboard.press('Escape') + await page.keyboard.press('Shift+Tab') + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(false) + await page.keyboard.press('Tab') + await expectBrand(trigger, 'shadow') + for (const key of ['Enter', 'Space']) { + await page.keyboard.press(key) + await page.getByRole('menu').waitFor() + await expectBrand(trigger, 'shadow') + const menu = page.getByRole('menu') + for (const [navigation, index] of [['End', -1], ['Home', 0]] as const) { + await page.keyboard.press(navigation) + const row = menu.getByRole('menuitem').nth(index) + const state = await paint(row) + expect(state.active).toBe(true) + expect(state.focusVisible).toBe(true) + expect(state.modality).toBe('keyboard') + // Menu rows intentionally use the hover fill instead of a second outline. + expect(state.background).toBe(state.hover) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + } + await page.keyboard.press('Escape') + await expectBrand(trigger, 'shadow') + } + expect(consoleWatch.warnings).toEqual([]) + expect(consoleWatch.pageErrors).toEqual([]) +}) + +/** + * Supplementary fixture, not an assembled app: Vite compiles the actual Menu, + * Switch, input-modality module and CSS Modules in memory. Only arrangement and + * state ownership belong to the fixture; no test writes the modality attribute. + */ +async function compileFixture(): Promise<{ script: string; css: string }> { + const source = (relative: string): string => JSON.stringify(join(REPO_ROOT, 'packages/client', relative).replaceAll('\\', '/')) + const entry = join(REPO_ROOT, 'apps/web/focus-rings-fixture.tsx').replaceAll('\\', '/') + const code = ` + import React, { useState } from 'react' + import { createRoot } from 'react-dom/client' + import { Menu } from ${source('ui-primitives/src/Menu.tsx')} + import { Switch } from ${source('ui-primitives/src/Switch.tsx')} + import ${source('ui-primitives/src/input-modality.ts')} + import ${source('ui-theme/src/styles/base.css')} + import ${source('ui-theme/src/styles/design-platform.css')} + import ${source('ui-theme/src/styles/focus.css')} + import ${source('ui-theme/src/styles/gradient-shadow-text.css')} + import triggerCss from ${source('ui-permission-presets/src/client/PermissionSelect.module.css')} + import feedbackCss from ${source('ui-message-feedback/src/client/FeedbackDialog.module.css')} + import workflowCss from ${source('ui-workflow-run/src/client/WorkflowRunPanel.module.css')} + import cardCss from ${source('ui-primitives/src/HoverCard.module.css')} + import pillCss from ${source('ui-primitives/src/Pill.module.css')} + import trajectoryCss from ${source('ui-trajectory/src/client/TrajectoryTable.module.css')} + function Fixture() { + const [checked, setChecked] = useState(false) + const [open, setOpen] = useState(false) + return
+ + + setOpen(false)} + anchor={} + items={[{ id: 'first', label: 'First item' }, { id: 'last', label: 'Last item' }]} /> +