merge: integrate main for github sync delivery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-20 15:39:13 +08:00
commit 2facedd598
68 changed files with 1968 additions and 241 deletions

View file

@ -86,6 +86,8 @@
| GET | `/api/v1/namespaces` | 公开命名空间列表 |
| GET | `/api/v1/namespaces/{slug}` | 命名空间详情 |
版本列表支持 `page`(从 0 开始)、`size` 和可选 `downloadableOnly`(缺省 `false`)。`true` 仅返回已发布、下载就绪且未撤回的版本,先过滤和排序后分页,`total` 为过滤后的数量。公开列表按发布时间倒序、空时间靠后,再按创建时间和 ID 倒序稳定排序。管理者缺省读取保留生命周期分组和未发布历史。过滤不探测对象存储,实际下载仍可能因文件缺失或网络错误失败。
Public API 的可见性规则:
- `PUBLIC` 技能:若存在已发布版本,则已登录用户可访问;匿名访问仍受下载/resolve 端点的 namespace 类型限制
- `NAMESPACE_ONLY` 技能:仅该命名空间成员可访问(需登录)

View file

@ -202,3 +202,8 @@ Client 通过官方槽位声明生命周期与 layout/configForms 服务依赖
已在不安装 SkillHub 的官方 RC.2 Web Profile 中复现:保持浏览器打开并在同一端口重启 Host,Client 模块图可能短暂缺少会话依赖,触发 `session-maybe` 适配器缺失并导致空白页;模块图随后恢复也不保证页面自动恢复。证据见 [官方 Host 对照](verification.md)。结论仅覆盖该 RC.2 组合,不外推其他版本。
插件显式依赖 `uiSession`,但不能保证宿主外层渲染器在模块卸载期间仍可用。首版不得宣称 Host 重启后的无缝重连通过。保留原标签页及存储、使用 Host 就绪后的有效入口在同一 origin 重新进入并查询原操作,是独立的恢复验收路径;不以重新提交安装或升级替代原操作查询。最终候选须验证这一流程,并分别记录自动重连失败与手动重新进入后的结果。
### 可选的原生范围问答
`@deepseek-ai/dsh-user-questions@0.2.0-rc.2` 是可选宿主能力,不加入安装文件写入的必需依赖基线。插件使用已验证的非计时 `ask` 接口和原生输入框选项;无服务或回答器时返回待确认卡片,并使用插件确认弹窗。新增问答能力不意味着接受其他未验证的核心 DSH/Cordis 组合,也不恢复跨宿主重启的旧安装授权。

View file

@ -92,7 +92,7 @@ flowchart TD
用户明确说“安装 @namespace/slug”,或在当前搜索语境中唯一指明“安装第二个”时,Agent 才可请求安装。单纯“找一个”“这个怎么样”不表示安装。不能只靠 prompt 承担文件保护:Host 对所有入口执行相同的来源、目标、版本、归属、锁和幂等检查,宿主策略步骤继续生效。
- 坐标有歧义时让用户选择,不猜测同名技能。
- 默认用户级;用户明确要求当前项目且 Host 能提供可信项目根时使用项目级,否则不降级成用户级安装。
- 用户明确指定范围时使用该范围;未指定时展示范围选择 UI,经用户确认后安装。没有可信项目根时禁用项目级,不静默降级成用户级。
- 按当前来源重新读取可安装快照;若用户依据先前详情指定了版本,保留该精确版本,不能静默换成最新版。绑定版本不可用则停止并展示原因。
- 来源或项目已切换时,历史卡片与旧请求不能自动迁移到新上下文;提示重新选择后再安装。
- 多个候选匹配而用户仅说“那个技能”等模糊指代时,先询问命名空间/slug 或结果编号,不选择第一项或任意引用;明确要求安装不等于目标已经明确。该规则由工具指引传达,Host 不宣称能强制识别自然语言意图。
@ -123,39 +123,43 @@ flowchart TD
| 工具 | 输入 | 返回 | 服务复用 |
| --- | --- | --- | --- |
| astron_skillhub_search | q 字符串 | 来源快照、查询、items(含 Host 签发的 selectionRef)、total/returned/limit、展示类型和协议版本 | listSkills;只读,Host 注入来源 |
| astron_skillhub_install | scope=user/project;selectionRef 或明确新输入的 namespace、slug(二选一) | accepted:operationId、调用/主机标识、技能身份/固定版本、来源/目标标签及接收时间;not-confirmed:调用标识与错误代码。文件和发现终态不包含在工具回执中 | getSkillDetail + startInstall;Client 另经 getOperation 查询终态 |
| astron_skillhub_install | scope 可选 user/project;selectionRef 或明确新输入的 namespace、slug(二选一) | accepted:operationId、调用/主机标识、技能身份/固定版本、来源/目标标签及接收时间;awaiting-confirmation:候选身份/精确版本及 UI 选择引用;not-confirmed:调用标识与错误代码。文件和发现终态不包含在工具回执中 | getSkillDetail + startInstall;Client 另经 getOperation 查询终态 |
工具名称与 schema 分别定义于插件的 search-tool.ts 和 install-tool.ts,注册时拒绝同名冲突;这些是 DSH Agent 工具,不是外部 HTTP API。scope 默认 user。sourceRevision、targetToken、requestKey 由可信适配层生成,不能信任模型构造的 token 或历史卡片 HTML 属性。selectionRef 由 Host 签发并保存,绑定来源修订、会话、搜索条目坐标和项目上下文;搜索阶段可只有候选版本,详情打开后另签发绑定精确版本/fingerprint 的引用。引用不是模型可编辑的字段集合。基于搜索卡片、详情或“第二个”等历史选择安装必须提交 selectionRef;Host 保存搜索引用到最近一次成功展示所用详情快照的关联;有已展示快照时必须提升到该快照,没有已展示快照时由 Host 按搜索时精确版本核验并固定安装快照,两者均不得重新解析 latest。若同一搜索引用关联多个不同固定版本且无法确定用户所指,则阻断并重新选择。Host 校验归属、时效、来源、项目和已有固定版本,失败返回 CONTEXT_STALE,不回退到最新版本。只有用户在当前上下文明示一个新的完整坐标时才可走无引用入口;工具引导与适配上下文须区分新输入和引用选择,不能把缺失或过期的选择引用改写为新坐标安装。该解析歧义无法消除时先让用户重新选择。
工具名称与 schema 分别定义于插件的 search-tool.ts 和 install-tool.ts,注册时拒绝同名冲突;这些是 DSH Agent 工具,不是外部 HTTP API。scope 仅在用户明确指定当前用户或当前项目时传入;未指定时省略,工具返回 awaiting-confirmation,不默认写入当前用户。sourceRevision、targetToken、requestKey 由可信适配层生成,不能信任模型构造的 token 或历史卡片 HTML 属性。selectionRef 由 Host 签发并保存,绑定来源修订、会话、搜索条目坐标和项目上下文;搜索阶段可只有候选版本,详情打开后另签发绑定精确版本/fingerprint 的引用。引用不是模型可编辑的字段集合。基于搜索卡片、详情或“第二个”等历史选择安装必须提交 selectionRef;Host 保存搜索引用到最近一次成功展示所用详情快照的关联;有已展示快照时必须提升到该快照,没有已展示快照时由 Host 按搜索时精确版本核验并固定安装快照,两者均不得重新解析 latest。若同一搜索引用关联多个不同固定版本且无法确定用户所指,则阻断并重新选择。Host 校验归属、时效、来源、项目和已有固定版本,失败返回 CONTEXT_STALE,不回退到最新版本。只有用户在当前上下文明示一个新的完整坐标时才可走无引用入口;工具引导与适配上下文须区分新输入和引用选择,不能把缺失或过期的选择引用改写为新坐标安装。该解析歧义无法消除时先让用户重新选择。
展示数据以结构化 envelope 传递,搜索与安装使用各自的 schema,不假设字段相同。搜索结果包含 kind、schemaVersion、查询、来源标签/修订、创建时间、数量与候选快照;toolCallId 由宿主 ToolView props.callId 提供。安装结果使用 §9 的 accepted/not-confirmed 两种结构,只有 accepted 包含 operationId。Host 保留本地记录归属;Client 不接收带凭证 URL、内部下载 URL 或可执行命令。工具通过 output.presentationMeta 提供结构化结果;Client 在 tool.call.toolview 插槽按工具名注册,从 props.block.meta 解析并校验。无法识别的历史格式显示只读提示,不触发安装。
展示数据以结构化 envelope 传递,搜索与安装使用各自的 schema,不假设字段相同。搜索结果包含 kind、schemaVersion、查询、来源标签/修订、创建时间、数量与候选快照;toolCallId 由宿主 ToolView props.callId 提供。安装结果使用 §9 的 accepted/awaiting-confirmation/cancelled/not-confirmed 四种互斥结构,只有 accepted 包含 operationId。Host 保留本地记录归属;Client 不接收带凭证 URL、内部下载 URL 或可执行命令。工具通过 output.presentationMeta 提供结构化结果;Client 在 tool.call.toolview 插槽按工具名注册,从 props.block.meta 解析并校验。无法识别的历史格式显示只读提示,不触发安装。
工具适配层按持久 hostInstanceId、会话、toolCallId 映射稳定 requestKey(进程重启不改变 hostInstanceId);相同工具调用重新等待不能重新写入。不同 toolCallId 的重复请求仍由共享目标锁、内容检查和 inventory 幂等处理,不能只做 UI 禁用。
该映射使用持久 hostInstanceId 作为 UUID v5 命名空间,名称为 JSON 数组 `["skillhub-conversation-install-v1", sessionId, callId]`;通过结构化编码避免字段拼接歧义。模型不提供 requestKey。参数变化不能产生另一个键以绕开冲突检查:原始 selectionRef 或新坐标及 scope 必须参与日志参数摘要,先查已有接受记录,再解析可能过期的引用。请求键用于幂等关联,不作为授权凭证。
该映射使用持久 hostInstanceId 作为 UUID v5 命名空间,名称为 JSON 数组 `["skillhub-conversation-install-v1", sessionId, callId]`;通过结构化编码避免字段拼接歧义。模型不提供 requestKey。参数变化不能产生另一个键以绕开冲突检查:原始 selectionRef 或新坐标及显式 scope 必须参与日志参数摘要;已明确范围的安装先查已有接受记录,再解析可能过期的引用。未明确范围的请求仅保存在当前 Host 的等待选择缓存中,同调用参数变化直接拒绝,不创建 accepted 日志。请求键用于幂等关联,不作为授权凭证。
```mermaid
sequenceDiagram
actor U as 用户
participant A as DSH Agent
participant T as 插件工具适配层
participant S as 共享 Host 服务
participant D as 安装确认界面
participant C as 对话卡片
U->>A: 明确安装所选技能
A->>T: astron_skillhub_install(selectionRef 或新坐标,范围)
T->>S: 核验上下文并解析精确版本/可信目标
S-->>T: detailToken 与 targetToken
T->>D: 展示技能、版本、来源和目标
U->>D: 确认安装
D-->>T: 确认结果
T->>S: startInstall(tokens,稳定 requestKey)
S-->>T: operationId
T-->>C: 结构化操作引用
C->>S: getOperation(operationId)
S-->>C: 文件与发现结果
Note over T,S: accepted 仅证明接收,不证明文件安装完成
Note over U,D: 取消确认时不调用 startInstall
Note over C,S: 短轮询只在视图可见且操作未结束时进行
participant H as 插件 Host
participant C as 结果卡片与详情抽屉
participant I as 共享安装事务
U->>A: 安装已识别的技能
alt 用户明确指定当前项目或当前用户
A->>H: install(selectionRef, scope)
H->>I: 幂等查重、核验固定版本和可信目标
I-->>H: accepted / operationId
H-->>C: 已接收的历史快照
else 用户未指定范围
A->>H: install(selectionRef),省略 scope
H-->>C: awaiting-confirmation,零写入
A-->>U: 请在卡片选择范围并确认
U->>C: 选择范围并安装
C->>H: openConversationDetail,读取精确版本
H-->>C: 详情与受控 token
U->>C: 选择当前项目/用户并确认
C->>H: startInstall,受控 token 与稳定请求键
H->>I: 真实安装事务
I-->>C: 操作引用
end
C->>H: getOperation 查询文件与发现结果
Note over C,I: 关闭或取消确认不提交安装;accepted 不表示安装完成
```
## 7. 实现与验收边界
@ -189,7 +193,7 @@ flowchart LR
## 9. 工具返回与操作完成的界线
安装工具先完成上下文核验和持久接收,成功返回 accepted/operationId;它不长时间阻塞到整个下载结束。工具返回文案必须是“已开始安装”,不能返回“安装成功”。InstallToolView 通过共享 getOperation 展示后续阶段,操作进入 completed/failed/recovery-required/unknown 后停止自动轮询。工具适配失败且尚未接受时返回结构化错误,不创建虚假操作。
用户已明确范围时,安装工具先完成上下文核验和持久接收,成功返回 accepted/operationId;它不长时间阻塞到整个下载结束。工具返回文案必须是“已开始安装”,不能返回“安装成功”。InstallToolView 通过共享 getOperation 展示后续阶段,操作进入 completed/failed/recovery-required/unknown 后停止自动轮询。工具适配失败且尚未接受时返回结构化错误,不创建虚假操作。
模型看到 accepted 只能说明任务已接收,不得断言技能可用或立即执行依赖该技能的动作。首期不增加后台向对话自动发消息的机制,也不新增第三个操作查询工具;最终状态由持久结果卡片展示。用户需在结果确认后自行继续任务。M0 必须证明 DSH 能保存 accepted payload 并让 ToolView 后续查询;不成立则记录兼容阻塞,修改本契约前复审。
@ -216,9 +220,21 @@ Host 从当前会话的持久日志读取匹配且唯一的成功搜索调用/
Agent 在下一次明确安装请求中仍可传原搜索引用,Host 根据关联解析到已查看版本,Client 无需向对话插入工具消息。不允许新版本发布导致重新 resolve latest。未成功展示的新快照不覆盖旧关联;一次来源/项目变化使整个引用链失效。历史快照的重新查看会展示新来源/版本,用户须重新发起安装。原引用关联多版本有歧义时返回 CONTEXT_STALE,由用户重新选择。
### 未指定范围的原生选择与确认
用户说“安装第二个”等未明确范围时,模型保留搜索 selectionRef 并省略 scope。Host 固定候选版本,优先调用 DSH `ctx.userQuestions.ask`,在聊天输入框旁临时展示单选问题:当前项目、当前用户、取消安装。问题列明技能完整坐标、精确版本、来源和范围说明;提交选项即确认安装,不要求再次打开详情。只列出 Host 实际可用的范围;不标记推荐或默认提交。
Host 可读取固定版本详情、指纹和目标供确认,但回答前不得创建操作、下载技能包或写入技能文件。仅识别对应问题的一条准确范围选项;取消、跳过、自定义答案、未知或多个答案均不提交。等待后重新校验来源、Agent 实例、会话、项目、运行实例、选择引用和目标凭据;不得重新解析 latest。重复同一调用共享一次问题和结果,同 callId 改参数仍拒绝。
使用非计时原生问答,带真实调用 Agent 和 AbortSignal;关闭、取消或执行中止不授权安装。不使用异步迟到回答跨重启恢复写权限。`cancelled` 结果只表示未提交,不包含 operationId,也不自动重试。
宿主没有服务或回答器时,降级为 `awaiting-confirmation` 卡片;该结果仍无操作。点击“选择范围并安装”直接打开紧凑确认弹窗,展示固定身份与版本、范围选择和取消/确认按钮,不打开完整详情。专用 pending 引用只用于 UI,不可直接作为工具安装凭据;来源、会话或运行实例变化后须重新搜索。后续用户在新消息中明确范围,可用原来仍有效的搜索引用发起新调用。
当前项目仅当前工作区使用,不自动同步给协作者;当前用户可在该 DSH 主机用户的多个项目中发现,实际读取仍受会话权限限制。
### 安装请求未确认的结果
安装工具的结果 envelope 使用互斥的 `accepted` 与 `not-confirmed` 两种状态。后者仅包含 schemaVersion、kind、toolCallId、code,不附加虚构 operationId 或原始诊断。已知错误代码为 CONTEXT_STALE、REQUEST_CONFLICT、SOURCE_NOT_CONFIGURED、SOURCE_DENIED、SKILL_UNAVAILABLE、RATE_LIMITED、SOURCE_UNAVAILABLE、TARGET_UNAVAILABLE;未识别异常使用 UNCONFIRMED。
安装工具的结果 envelope 使用互斥的 `accepted`、`awaiting-confirmation`、`cancelled` 与 `not-confirmed` 四种状态。`not-confirmed`仅包含 schemaVersion、kind、toolCallId、code,不附加虚构 operationId 或原始诊断。已知错误代码为 CONTEXT_STALE、REQUEST_CONFLICT、SOURCE_NOT_CONFIGURED、SOURCE_DENIED、SKILL_UNAVAILABLE、RATE_LIMITED、SOURCE_UNAVAILABLE、TARGET_UNAVAILABLE;未识别异常使用 UNCONFIRMED。
Client 校验代码后显示本地定义的原因和处理提示,不从错误记录查询或提交操作。参数格式或宿主执行上下文缺失仍属于工具调用错误。工具执行框架的 isError=false 只表示产生了符合 schema 的结果,业务成功须检查 status;not-confirmed 不表示已安装,也不能证明没有任何写入。接受后发生的下载或落盘失败继续通过原 operationId 查询,不改写接受记录。

View file

@ -236,3 +236,8 @@ flowchart TB
## 对话入口与共享架构
对话搜索与安装纳入首期。Host 工具适配器和 Client 广场 Remote 适配器调用相同应用服务:Registry API 获取内容,内置 CLI 核心写入文件;不执行 PATH CLI。Client 共用 DetailContent,分别装入广场子页面与对话抽屉。安装操作归属、锁、日志与幂等不因入口改变;详见 [对话设计](conversation-design.md)。
## 版本目录的开源接口增量
排序、可安装状态过滤和分页总数由开源后端统一计算;DSH 每页请求一次 `downloadableOnly=true`,不全量拉取或逐项 resolve 探测。固定版本安装仍经既有目标、指纹与包校验,管理者缺省历史行为保留。完整参数和排序契约见 [Registry API](registry-api-contract.md#版本目录的开源接口增量);SaaS 同步单独验收。

View file

@ -106,6 +106,8 @@ interface SkillHubPluginConfig {
| `startInstall` | input:detailToken,targetToken,requestKey,hostInstanceId,mode,initiatedFrom;可信 agent scope | NativeOperation 或 null | 持久接受后返回操作快照,同 key 同参数返回原操作;retry 未找到记录返回 null,不创建操作 |
| `listInstallTargets` | detailToken,可信 agent scope | hostInstanceId、targets(targetToken、scope)、unavailable(范围列表) | 只读;从 Agent 文件系统解析,来源/项目/详情变化时拒绝;不返回路径,不代表已授权写入 |
| `checkUpdate` | sourceRevision,itemKey | 更新状态、当前/候选版本、checkedAt | 只读,不下载完整包 |
| `checkVersionUpdate` | sourceRevision,itemKey,version | InstalledUpdateCheck | 按所选精确版本检查,不回退 latest |
| `prepareVersionUpgrade` | sourceRevision,itemKey,version | UpgradePlanView | 固定精确候选,复用完整目标升级计划 |
| `prepareUpgrade` | sourceRevision,itemKey | UpgradePlanView | 解析全部共享目标,仅允许当前用户/项目范围 |
| `prepareUninstall` | sourceRevision,itemKey | UninstallPlanView | 只解析一个目标;离线可用 |
| `executePlan` | input:planId,requestKey;可信 agent scope | NativeOperation 或 null | 校验计划和精确 before,提交后不能复用 |
@ -290,7 +292,7 @@ local-only 详情不能发起远端安装或升级,但可通过独立 prepareU
## 对话工具接口
首期提供 astron_skillhub_search 与 astron_skillhub_install。模型仅提供关键词或技能坐标/范围;来源、可信目标、幂等键由 Host 适配层派生。具体输入、结果 envelope、历史操作绑定与错误语义见 [对话设计第 6 节](conversation-design.md#6-工具与服务契约)。页面与工具均复用本文 startInstall/getOperation,工具不接受任意文件路径或下载地址。
首期提供 astron_skillhub_search 与 astron_skillhub_install。模型仅提供关键词或技能坐标/范围;来源、可信目标、幂等键由 Host 适配层派生。具体输入、结果 envelope、历史操作绑定与错误语义见 [对话设计第 6 节](conversation-design.md#6-工具与服务契约)。页面与工具均复用本文 startInstall/getOperation,工具不接受任意文件路径或下载地址。用户未明确范围时省略 scope,工具仅返回 awaiting-confirmation(无 operationId),卡片经 openConversationDetail 和既有 InstallControl 选择范围后才调用 startInstall;该等待状态只固定候选版本,不固定指纹、不下载包、不创建安装事务,也不能由历史恢复自动重放。
## 10. 已接受操作的查重顺序与历史快照
@ -298,7 +300,7 @@ local-only 详情不能发起远端安装或升级,但可通过独立 prepareU
本节定义插件内部协议。Host 启动在插件状态目录读取或首次生成 hostInstanceId,重启保持不变;状态目录丢失或迁移到新 Host 时视为新身份,不能用显示名称猜测同一主机。会话/调用身份来自宿主可信上下文;具体传递能力须 M0 验证。
1. 建立当前调用归属:hostInstanceId、会话/Client 归属。无法确认归属不返回另一会话/主机的操作。
2. 对 startInstall/executePlan/工具安装,先以该归属与 requestKey 查持久 accepted 记录,对原始参数摘要 requestDigest 做一致性比较。摘要包括原 token/ref 或计划引用及显式参数,不只比较当前 resolve 结果。
2. 对 startInstall/executePlan/已明确范围的工具安装,先以该归属与 requestKey 查持久 accepted 记录,对原始参数摘要 requestDigest 做一致性比较。摘要包括原 token/ref 或计划引用及显式参数,不只比较当前 resolve 结果。
3. 同 key 同摘要已存在时返回原 operationId 与保存的 context;即使旧 token 已过期或配置已变化,也仅恢复查询,不开始新写。同 key 不同摘要返回 REQUEST_KEY_CONFLICT。
4. 无记录且这是已知的重试/恢复请求时返回 unknown,不自动转换成新操作。Client 为首次请求创建键后记录其提交状态;工具映射随 accepted 记录持久化。身份/日志丢失无法证明首次请求时亦按 unknown 处理。
5. 只有可确认为新操作才进入当前 token、sourceRevision、项目、版本及计划 expiry 校验;持久化 accepted、requestDigest 与稳定调用映射成功后才能开始写文件。
@ -332,3 +334,29 @@ accepted 不表示安装完成。工具立即返回 accepted 及 operationId,
`testRegistryConnection(inputUrl, signal)`仅供配置页显式测试,不注册Agent工具。Host创建独立RegistryAdapter并验证HTTP(S)地址,不接受凭证、查询参数或片段;匿名GET、不跟随重定向、响应体仍使用既有大小限制。探测目录第一页,接口缺失时沿用基础搜索回退,整个测试最多10秒。请求失败返回既有`skillhub/registry-error`安全原因,不暴露响应正文或凭证。
测试不使用当前来源写路径,不改变配置、sourceRevision、目录缓存或本地文件;不尝试下载或安装。Client取消旧请求并以请求代次隔离结果,防止地址变更后显示旧地址成功信息。mode为paged仅说明分页目录可读,limited仅说明基础搜索可读,二者均不证明写入或可选Tab能力。
### 原生安装范围确认
安装工具省略 scope 时由 Host 使用可选的 `ctx.userQuestions.ask` 显示输入框旁的单选题,问题含固定版本、来源、实际可用范围及取消。回答必须匹配该问题的一条准确范围选项;取消、空、自定义或多个答案返回 `{schemaVersion:1,kind:"skillhub-install",status:"cancelled",toolCallId}`,不含操作 ID。用户回答后重新检查上下文,再复用现有安装事务。无服务或回答器时保留 `awaiting-confirmation` 卡片;该卡片直接打开确认弹窗,不打开详情抽屉。显式 scope 的原流程保留。
此能力是宿主可选 peer,不改变既有写入依赖基线。不使用新计时问题 API,不回放旧提问授权,不向模型发送目录或目标 token。问答取消与执行中止不转换成用户级安装。
### 安装进度与列表刷新边界
复用原 startInstall、requestKey、getOperation 和共享请求存储,不增加写入接口。提交后固定范围;响应丢失核验使用原 requestKey 的 retry,不能创建新写入意图。弹窗展开不导航、不重读技能详情。只在操作首次进入结束阶段或结束阶段变化时使当前会话的库存投影失效;重复读取同一结束阶段不重复刷新。后台库存读取保留已有卡片和状态,写入资格继续由完整核验及 Host 事务边界保护。
### 原地卸载组件与接口复用
`RemovalControl` 支持面板与弹窗两种呈现,使用同一 `prepareUninstall → executePlan → getOperation` 流程及 `RemovalRequests` 幂等请求。市场与已安装列表只保存选中的库存项并打开弹窗,不调用技能详情读取接口,也不改变当前 Tab 或详情选择。清理失效记录仍使用原清理计划,不能转成删除不存在目录的普通卸载。
确认框默认隐藏 URL、路径和绝对到期时间,但保留来源、会话、目标、文件基线和过期核验;用户取消只关闭本次 UI。关闭已提交但未确认的结果保留原请求;只有读取到可关闭的结束状态时才清除前端请求记录。原始事务日志和恢复数据不被 UI 简化删除。
### 选定版本升级
保留 `checkUpdate` 与 `prepareUpgrade` 的原签名及“检查最新版”行为,新增 Agent-scoped `checkVersionUpdate(sourceRevision,itemKey,version,signal)` 和 `prepareVersionUpgrade(sourceRevision,itemKey,version,signal)`。两组接口共享当前库存重读、完整目标基线、版本比较、文件修改保护与确认计划;定向接口必须按指定 version 解析,响应版本不一致即拒绝,不使用 latest 替换不可用版本。执行仍只提交原 executePlan,无新文件写入接口或 SaaS 安装接口。
详情按 source/session/itemKey/所选 version 隔离只读检查缓存,切换仅触发读取。较新且可核验的版本显示升级到该版本,相同版本不重复显示升级,旧版本或不可排序版本明确阻断。确认使用捕获的库存项与精确目标,不因后台库存刷新重建另一升级意图;升级完成使库存失效,旧 itemKey 的检查结果不会绑定到新记录。

View file

@ -275,3 +275,8 @@ Inventory 变更必须在 mutateAtomic 的最新快照里比较旧身份、版
## 11. 幂等安装的发现核验
同版本已安装时不创建写事务,也不生成虚假的 inventory 提交。核心在目标和清单锁内验证现有安装,并随原操作原子保存只读基线 `existingInstallation`:`versionId`、`fingerprint` 与 `inventoryDigest`(相关清单内容的 SHA-256 摘要);操作本身保留原坐标、版本和目标。后续发现观察须在调用 DSH 前后重新核验原基线、归属、可信根与操作修订;内容或记录变化即保持未确认。旧操作没有该基线时不使用当前库存补写历史成功证据。此基线属于 Host 内部日志,不增加 Client Remote 字段,也不向 Client 暴露路径或内部清单。
### 定向升级补充
用户在详情选中精确版本时,先通过 checkVersionUpdate 核验升级方向,再通过 prepareVersionUpgrade 固定候选;最新版检查与计划仍使用原接口。固定候选不可用或版本不符时阻断,不回退 latest。用户确认的计划列出全部共享库存目标,执行使用原锁定基线与候选快照。较旧与不可排序版本不允许覆盖安装;取消计划不写文件。

View file

@ -42,7 +42,7 @@ SkillHub 网站负责发布、审核与维护内容;Registry 提供技能数
| --- | --- |
| DSH 原生包、一个 SkillHub 入口 | DSH 插件市场、网站重构 |
| 匿名列表、关键词搜索、详情 | 登录、Token、私有访问、账号与权限管理 |
| 默认用户级,可选可信当前项目 | 任意安装路径、多目标批量安装 |
| 页面预选可信项目,不可用时预选用户;对话未指定需选择确认 | 任意安装路径、多目标批量安装 |
| 完整安装、主动升级、精确卸载、冲突保护 | 自动更新、降级、强制覆盖本地修改 |
| 一个来源地址的必要配置 | 多来源聚合、认证中心、收藏和排行榜 |
| 对话搜索卡片、详情抽屉、显式安装工具、结果卡片 | 自动发现后安装、模型自主升级卸载 |
@ -103,7 +103,7 @@ flowchart TD
| 工具栏 | 紧凑标题、来源信息入口、关闭 X | 来源是配置实例的显示地址/标签;关闭动作沿用宿主会话能力,无登录状态 |
| 搜索区 | 输入框、清空、搜索按钮 | Enter 与按钮行为一致;按提交查询,不逐字触发网络请求 |
| 结果说明 | 当前返回 N 项 | 如 total 可信且大于 N,可写“共匹配 T 项,当前显示 N 项” |
| 卡片网格 | 图标、slug/名称、完整坐标、摘要、版本、查看详情 | 卡片不提供安装按钮,避免跳过判断 |
| 卡片网格 | 图标、名称、完整坐标、摘要、版本、安装状态及操作 | 点击卡片进入详情;底部安装直接弹确认框,卸载、检查更新/升级使用独立按钮,保留预检与确认 |
| 空/错误区域 | 原因与恢复动作 | 不用空列表掩盖接口错误 |
### 4.2 卡片字段与紧凑布局
@ -114,9 +114,9 @@ flowchart TD
- 标题优先 displayName,缺失回退 slug,15px/20px、字重 600;坐标 12px/18px,单行省略但可通过完整提示和详情读取。
- 摘要 13px/20px,最多两行;空摘要显示“暂无简介”。系统字体优先继承 DSH,保留中文系统字体回退;不用等宽小字或缩放卡片实现悬停。
- 版本取 `publishedVersion.version`,不使用可能指向草稿的 headlineVersion。无发布版本的条目不提供安装卡片。
- 底部单行展示下载量、收藏数、评分及详情入口。三个指标独立可选;缺失、null 或非法值隐藏,下载量/收藏数为 0 时显示 0,无评价不显示 0 分。评分只读,不增加收藏或评分写操作。
- 底部紧凑展示下载量、收藏数、评分及安装管理操作。三个指标独立可选;缺失、null 或非法值隐藏,下载量/收藏数为 0 时显示 0,无评价不显示 0 分。评分只读,不增加收藏或评分写操作。
- 数量小于 10000 使用千分位,大于等于 10000 使用“万”;提示和可访问名称保留完整数值。增加指标不增加卡片高度。
- 整张卡片只有一个明确的详情打开动作,避免嵌套按钮。浏览列表不逐卡请求指标或扫描本地目录。
- 整张卡片支持鼠标与键盘打开详情,操作按钮与详情触发元素为同级,不嵌套按钮,操作不触发详情。未安装时不显示“无本来源记录”;本来源已安装时显示版本和范围。多个范围需先选择目标,再进入既有卸载或升级确认。升级仅在检查更新确认可升级时显示,不将日期版本字符串比较当作升级依据。安装状态读取失败显示统一提示,不能当作未安装。浏览列表不逐卡请求指标或扫描本地目录。
### 4.3 浏览与搜索规则
@ -140,10 +140,10 @@ flowchart TD
| --- | --- |
| 名称、坐标、摘要 | 搜索快照先展示;详情返回后校正,不修改所选身份 |
| 安装版本 | 打开详情时 resolve 一次获得精确版本;后续文档与安装绑定该版本 |
| 技能说明 | 读取该版本 SKILL.md,安全渲染 Markdown;保留技能原文,不用 AI 自动补写功能 |
| 技能说明 | 读取该版本 SKILL.md,安全渲染 Markdown;移除完整的起始 YAML/TOML frontmatter,名称和摘要由身份区展示,正文按标题、列表、引用和代码块分层排版;保留技能正文,不用 AI 自动补写功能 |
| 使用方式/注意事项 | 原文有才展示;图片中的章节仅为示例,不要求后端返回这些独立字段 |
| 来源 | 当前配置实例,不展示内部带查询参数的下载链接 |
| 安装范围 | 用户级默认;当前项目可确认时提供项目级 |
| 安装范围 | 可信当前项目优先;无项目时预选用户级,仍须明确确认 |
| 可安装性 | 目标检查、包兼容性及下载可能分别失败,详情可读不等于可安装 |
说明缺失或读取失败时显示“详细说明暂不可用”及重试。只要精确版本 resolve 可用,可继续尝试安装;安装仍需完整包校验。若技能本体变为不可访问或 resolve 失败,则禁用安装。
@ -161,7 +161,7 @@ Markdown 不执行 HTML/脚本;外部链接明确打开新页面,阻止危
版本、安全审核、评论支持 `auto / show / hide` 实例级配置,放在宿主插件配置内。开源和 SaaS 使用同一插件,不按产品名称硬编码。比如 SaaS 未上线评论时设置 hide,开源实例可在匿名读取已验证后 auto 展示。
`auto` 只有确认支持匿名读取才展示;`show` 保留入口但错误仍如实展示;`hide` 不展示且不请求。空数据保留 Tab 并显示“暂无版本/评论/审核报告”,网络错误保留重试。未知能力不能被当作“没有数据”。 auto 探测未确认时,在详情内容上方说明对应模块尚未确认,并提供“重新检查可选内容”;点击重新读取当前固定版本详情并探测未确认模块。确认可用后显示对应 Tab,正文仍在激活时读取。详情永不隐藏;隐藏当前选中 Tab 后回到详情。详见 [接口配置规则](interface-design.md)。
`auto` 只有确认支持匿名读取才展示;`show` 保留入口但错误仍如实展示;`hide` 不展示且不请求。空数据保留 Tab 并显示“暂无版本/评论/审核报告”,网络错误保留重试。未知能力不能被当作“没有数据”。 auto 探测未确认时不展示全局提示或“重新检查可选内容”按钮;已显示模块的请求错误在各 Tab 内提供重试,重新进入失效详情时重新探测。确认可用后显示对应 Tab,正文仍在激活时读取。详情永不隐藏;隐藏当前选中 Tab 后回到详情。详见 [接口配置规则](interface-design.md)。
**当前源码限制:安全审核 Controller 明确拒绝匿名访问。** 默认 auto 下隐藏;显式 show 时显示“当前来源的审核信息不支持匿名读取”。本期不改后端访问政策、不增加登录。下图展示可选模块已启用且可匿名读取、但当前版本暂无报告的假设实例,不代表当前仓库已经支持匿名审核。
@ -446,3 +446,45 @@ Skeleton沿用局部shadcn组件风格和DSH主题变量,不引入新依赖或
来源输入旁增加小号“测试连接”按钮,读取当前输入的草稿地址,不要求先保存,也不修改来源配置或已安装记录。测试从DSH Host发起匿名只读请求,验证公开技能目录接口;旧来源可使用既有基础搜索回退。结果只展示通过/失败、公开目录或基础搜索可用、请求耗时及必要错误说明,不宣称安装、审核、评论或模型调用均已可用。测试通过不等于配置已保存。
测试期间禁用重复提交,整个测试含回退最多10秒。地址修改、重置或组件卸载取消旧测试,旧地址结果不得覆盖新草稿。空地址不发请求,非法地址给出明确提示。测试、保存与重置按钮统一使用小号规格,保持清晰焦点与禁用状态。
## 19. 市场操作与详情读取规则
- 标签筛选值始终使用接口 slug;优先展示服务端中文名称,已知内建英文分类由插件提供中文名称,自定义未知标签保持原名,不能凭翻译改变筛选值。
- 版本列表使用开源接口 `downloadableOnly=true`,服务端在分页前按可安装状态过滤,再按发布时间倒序排列,空时间靠后;同时间按创建时间和 ID 倒序稳定排列。总数为过滤后的总数。不可下载的生命周期版本不展示;实际存储丢失仍由固定版本解析/下载报错,不能把网络超时当作版本永久下架。
- 公开读取缓存 60 秒,键包含来源、会话和固定版本等身份;返回详情、切换 Tab 时复用有效数据和选中位置。后台刷新保留现有内容;来源或会话改变不能复用旧上下文,过期安装凭据需重新读取。安装、升级、卸载后失效相应安装状态。
- 对话明确指定范围时沿用现有安装流程;未指定时优先在输入框旁显示宿主原生范围选项,选择并提交才安装;无原生回答器时显示待确认卡片,并直接弹范围确认框。回答前不写文件、不创建安装操作;取消、跳过或失效后不自动安装。
### 19.1 直接安装确认与原生输入框选择
市场卡片的“安装”不改变浏览位置、不打开详情页,直接展示技能名、坐标、固定版本和范围选择。有效项目默认预选项目,无可用项目时禁用该项并预选用户;确认按钮在技能、目标和库存核验完成前禁用。取消、Escape、关闭只关闭弹窗,不提交;已提交后的关闭不取消操作。详情页原有安装入口保留。
对话优先使用 DSH 官方 user-questions 交互,在输入框旁临时作答;用户可选择项目、用户或取消。确认后复用原安装事务和结果卡,不要求模型再猜测答案。该界面由 DSH 渲染,插件不覆盖或手工插入宿主输入框 DOM。
### 安装确认后向右展开进度
市场卡片和对话降级安装入口共用安装弹窗。确认前展示技能、固定版本和可选择的安装范围;点击确认并保存原请求后,弹窗保留并向右扩展。左侧固定展示技能、版本、已确认范围及关闭按钮;右侧只显示当前安装阶段、进度条和简短结果。桌面空间允许时保持左边界,窄屏在同一弹窗内纵向排列。关闭不取消已提交的操作。
提交、下载校验、位置核验、写入期间使用不定进度条,不模拟百分比;只有操作完成时显示完成进度。文件完成与 DSH 已发现分别描述。失败、结果不明、恢复及告警保留简短提示和核验入口,不伪装成功。来源地址、时间戳、历史库存、原始回执和多重核验按钮不占安装弹窗。
### 安装范围与当前状态
页面安装范围使用“仅此项目 / 所有项目”,分别对应项目级与当前 Host 用户级;“所有项目”不扩展到其他用户或其他 Host。只保留一行范围说明。已有安装时默认选中其范围;两处均有记录时优先当前项目,用户可手动切换。只在另一范围安装时,显示已有安装位置,按钮明确指向当前选择的另一范围,不自动复制。
详情和重新打开的安装弹窗以当前库存为准:已安装显示版本与卸载入口,异常记录显示管理入口,未安装显示明确范围的安装按钮。已完成的历史安装回执不再混入此处;卸载后不得同时展示旧成功条和未安装状态。卸载复用现有目标核验和确认,不因范围改名改变目录或权限。尚在执行或结果不明的安装继续查询原请求,不创建第二个写入意图。
### 原地卸载确认
市场卡片、已安装列表以及技能详情的卸载入口均直接打开确认弹窗,不为卸载跳转到已安装详情。多范围记录沿用范围选择,确认只作用于所选范围。弹窗使用与安装一致的 shadcn/Radix 结构,只展示技能、固定版本、卸载范围及一条范围影响说明;不默认展示 Registry URL、本地绝对路径、目标主机、原始计划编号或到期时间。
打开后先只读核验,期间确认按钮禁用;确认才提交原计划,取消和 Esc 不写入。计划过期、文件修改、共享归属或来源变化仍阻断,错误直接展示简短原因。处理中、完成及结果不明均在同一弹窗内反馈,保留原请求查询及必要恢复入口。操作结果和底层核验数据仍由原事务与历史记录保存,不依赖 UI 隐藏与否。
### 切换版本与升级
切换版本是查看,不自动改变安装。已有安装时,查看的精确版本比当前版本新且通过来源核验,操作区显示“升级到 vX”;相同版本仅显示已安装,较旧版本说明不支持降级,不提供覆盖安装;不可可靠比较的版本阻断自动升级。不可访问版本不回退到最新版。
升级使用原地确认弹窗,展示旧版本→精确新版本及实际受影响的全部范围;当前底层共享库存升级可能同时更新用户和当前项目的安装,不能暗示只更新下拉框选中范围。存在当前会话不能管理的其他目标时仍整体阻断。市场和已安装列表的检查更新继续面向最新版,也使用同一精简弹窗。取消不修改文件;确认后显示处理与结果,异常保留原请求核验和恢复。

View file

@ -177,3 +177,15 @@ sequenceDiagram
### 可选公开标签能力
插件标签筛选复用开源已有 `/api/web/labels` 和 `/api/web/skills` 的 `label` 查询参数,无新增后端路由。SaaS 同步时应核对公开标签投影、匿名策略及筛选实际生效;未通过不能声明具备标签筛选。标签目录 404/405 时普通浏览继续可用;标签过滤请求失败必须明确提示,不得静默退回全部结果。
## 版本目录的开源接口增量
`GET /api/v1/skills/{namespace}/{slug}/versions` 增加可选参数 `downloadableOnly`,缺省 `false`,保留管理端版本历史。DSH 版本 Tab 使用 `true`;服务端沿用现有可见性检查,仅保留 `PUBLISHED`、`downloadReady=true`、未撤回的版本,在分页前按 `publishedAt DESC NULLS LAST`、`createdAt DESC NULLS LAST`、`id DESC` 排序,`total` 为过滤后的总量。普通公开版本读取也按相同时间顺序;管理者缺省读取保留生命周期分组顺序。
此过滤描述领域可安装状态,不逐项探测对象存储;实际文件丢失由既有解析、下载路径处理。插件每页只请求一次版本接口,不全量拉取历史或并发 resolve 探测。固定版本安装仍重新校验目标和指纹。SaaS 后续需同步参数、排序、过滤和 total 契约后独立验收,本轮不代表 SaaS 已同步或发布。
### 选定版本升级的 Registry 复用
定向升级仍使用现有 `GET /api/cli/v1/skills/{namespace}/{slug}/resolve?version={精确版本}` 与固定版本下载路径,不新增 SaaS 文件安装或升级 API。插件新增的 checkVersionUpdate、prepareVersionUpgrade 是本地 DSH Host 的 Agent-scoped 类型化方法,不是 SaaS HTTP 接口。resolve 返回其他版本时拒绝;不可用候选不重新请求无 version 的 latest。原最新版检查/计划方法继续使用无 version 的解析。

View file

@ -114,3 +114,12 @@ bun run probe:registry --registry 'https://registry.example' --namespace 'demo'
交付 SaaS PR、接口差异表、变更文件说明、测试命令和结果、OpenAPI 更新、测试环境部署版本、脱敏探针报告、真实 DSH 验收记录、可选能力配置和未完成项。大文件、截图、日志按 SaaS 仓库规范存放,不将私人数据或临时预览产物堆进 PR。
必需接口在目标测试环境通过匿名契约和 DSH 安装验收后,才能声明对应 SaaS 部署可用。未验证的生产环境、其他租户、DSH 版本、平台及真实模型效果单独列出,不扩大结论。
## 待同步:版本目录过滤与排序
开源先实现,本轮不改 SaaS 现网。请按 [Registry API 增量契约](registry-api-contract.md#版本目录的开源接口增量) 同步以下内容:
- 版本列表接受 `downloadableOnly=true`,过滤在分页前执行,`total` 与过滤一致。
- 公开版本全局按发布时间倒序排列;不改变缺省管理历史和技能访问政策。
- 使用发布时间与创建顺序相反、多页、不可下载及撤回版本测试,再用发布插件独立验证;存储不可达与领域不可安装分开处理。

View file

@ -103,9 +103,21 @@ The plugin SHALL provide a read-only skill search tool with clickable cards in t
The plugin SHALL provide an installation tool and a custom operation result card for explicit user installation requests. It SHALL use the same Host installation service, trusted targets, immutable version selection, conflict protection and operation journal as the plaza. It SHALL NOT accept arbitrary paths or download URLs from the model or automatically upgrade, uninstall or execute skills.
#### Scenario: Unambiguous explicit request
- **WHEN** the user explicitly requests installation of an identified skill
- **WHEN** the user explicitly requests installation of an identified skill and specifies current project or current user scope
- **THEN** the Host resolves a fixed version and trusted scope, starts a shared installation operation, and presents separate file and discovery outcomes
#### Scenario: Installation request without a specified scope
- **WHEN** the user requests installation of an identified skill without specifying current project or current user
- **THEN** the Host presents a native input-adjacent scope question bound to the selected exact version; it MAY read detail and available targets but SHALL NOT create an operation, download the skill archive or mutate installed files before an exact answer
- **AND** the native question offers only available project/user targets plus cancellation; if the service or answerer is unavailable the result is awaiting-confirmation and a card opens a compact confirmation dialog without detail navigation
- **AND** the model does not claim installation started before human confirmation or issuing another call to select a scope on the user's behalf; a later user message explicitly choosing a scope may initiate a new call with the original still-valid search reference, never the dedicated pending reference
#### Scenario: Pending confirmation is canceled or becomes stale
- **WHEN** the user closes the confirmation or its source, conversation, project, Agent, runtime or Host changes
- **THEN** no installation is replayed and stale choices require a fresh search rather than coordinate or latest-version fallback
- **AND** an unchanged repeated call keeps the original pending version while changed arguments under that call are rejected
- **AND** pending history has no operation identity, cannot restore write authority and remains a query-time snapshot after a UI-confirmed installation
#### Scenario: Ambiguous identity or unavailable project
- **WHEN** the requested identity is ambiguous or the requested project has no trusted root
- **THEN** no installation starts and the user is asked to select the skill or a valid target without silently switching scope
@ -321,3 +333,90 @@ After a Host restart, the plugin SHALL distinguish durable operation identity fr
- **WHEN** the historical operation completed but the skill was subsequently upgraded or uninstalled
- **THEN** the receipt preserves the original operation result while separately showing the current inventory
- **AND** it does not claim that the original version is still installed or submit a replacement installation
### Requirement: Compact market actions and readable cached details
The market SHALL use an independent whole-card detail trigger and sibling install-management buttons, omit ordinary no-source-record badges, and require a target when multiple installed scopes exist. Built-in labels SHALL have Chinese display names without changing filter slugs. Details SHALL omit complete leading Markdown frontmatter and global optional-module retry clutter. Public reads MAY be reused for 60 seconds using source/session/version identities; cached content SHALL remain visible during refresh and stale write references SHALL be revalidated.
#### Scenario: Card action does not open the detail page
- **WHEN** a user chooses remove or upgrade on a card
- **THEN** only the chosen installed target enters the existing preflight/confirmation flow
- **AND** the detail navigation trigger does not fire
#### Scenario: Return to a recently read detail
- **WHEN** the same session and source return within the read-cache window
- **THEN** the detail content and selected tab are reused without a blank loading replacement
- **AND** installation still uses valid Host context rather than trusting cached presentation
### Requirement: Direct market installation and safe native answers
Clicking a market install action SHALL open a compact scope-confirmation dialog without navigating to the detail page. When current installation records exist, the default scope SHALL prefer an installed scope (project first when both exist); otherwise it SHALL be the trusted current project when available, then current user; a default selection SHALL NOT submit installation. The native conversation question SHALL carry the exact live Agent and cancellation signal. Only one exact supported scope answer SHALL allow a single transaction after all source/session/runtime/reference bindings are rechecked. Skipped, custom, cancelled, ambiguous or stale answers SHALL NOT install. Missing native providers SHALL retain the no-write pending-card fallback.
#### Scenario: Native scope question is canceled
- **WHEN** the user cancels or skips the question instead of selecting a supported scope
- **THEN** the tool returns cancelled without an operation identifier or filesystem mutation
- **AND** no automatic retry or default-scope installation occurs
#### Scenario: A card install is canceled
- **WHEN** the user opens the install dialog from a card and cancels it
- **THEN** no installation request is submitted and the catalog, scroll position and focused trigger remain available
### Requirement: Installation confirmation expands into compact progress
After an installation is explicitly confirmed and its original request is saved, the plugin SHALL keep the dialog open and expand it to the right where viewport space permits. The left column SHALL retain the skill, fixed version and selected scope. The right column SHALL show compact real operation progress and result without raw operation history. Narrow viewports SHALL stack the columns within the dialog. Closing SHALL NOT cancel an already submitted installation.
#### Scenario: Installation submission is waiting for a response
- **WHEN** the saved installation request is submitting and has no operation response yet
- **THEN** the right panel shows an indeterminate submitting state rather than an unknown-result warning
- **AND** expansion does not navigate or re-read skill details
#### Scenario: Operation completes and is read repeatedly
- **WHEN** the operation first enters a terminal phase
- **THEN** current-session installation projections refresh while existing cards remain visible
- **AND** repeated reads of the same terminal phase do not trigger repeated inventory refreshes
- **AND** only completed operations show full progress, with discovery and warning outcomes accurately distinguished
### Requirement: Current installed inventory owns installation management state
The plugin SHALL derive current installation state and removal actions from the current source/session inventory, separately from historical operation receipts. An existing installation SHALL display its version and an explicit management action in its selected scope without a duplicate installation action. Completed historical receipts SHALL NOT be displayed as current installation success after removal. Scope presentation SHALL distinguish project-only availability from current-user availability across projects without changing Host paths or permissions.
#### Scenario: User-level installation is opened in details
- **WHEN** a skill has a user-level record and no current-project record
- **THEN** details default to the user-level scope and display the installed version with a removal entry
- **AND** explicitly selecting the project scope offers a separate project installation without submitting it automatically
#### Scenario: A completed installation was later removed
- **WHEN** fresh inventory confirms that the selected installation is absent
- **THEN** the control shows an explicit installation action instead of the old success receipt
- **AND** new explicit confirmation may supersede the completed request while unresolved requests retain their original request identity
### Requirement: Removal opens a compact confirmation in place
Removal actions from the market, installed list and skill detail SHALL open confirmation in place rather than navigate to another detail screen. The confirmation SHALL show the selected skill, version, scope and scope-specific impact, while omitting routine raw Registry URLs, Host paths and plan expiry timestamps. Existing preflight, expiry, identity, fingerprint, confirmation and recovery protections SHALL remain unchanged.
#### Scenario: Open and cancel removal
- **WHEN** the user opens removal from an installed card
- **THEN** the original page remains underneath a confirmation dialog and the plugin performs read-only preflight
- **AND** confirmation remains disabled until the plan is ready
- **AND** cancellation or Escape performs no removal and restores focus to the originating trigger
#### Scenario: Confirm removal
- **WHEN** the user confirms a ready unexpired plan
- **THEN** the original idempotent request submits exactly once and progress/result remains in the same dialog
- **AND** inventory refreshes when the operation settles without navigating to detail
- **AND** failed, unknown or recovery-required results never appear as successful removal
### Requirement: Selecting a newer version offers an exact-version upgrade
Selecting a version SHALL be read-only. For an installed skill, a newer verifiable selected version SHALL offer an explicit upgrade to that exact version. The plugin SHALL preserve existing latest-version update methods while adding agent-scoped exact-version checks and preflight. Unavailable or mismatched selected candidates SHALL NOT fall back to latest. Same versions SHALL NOT offer duplicate upgrades; older or unorderable versions SHALL explain why upgrade is blocked.
#### Scenario: Upgrade to a selected non-latest version
- **WHEN** version 1.0 is installed, 1.2 is latest and the user selects 1.1
- **THEN** checking and confirmation bind 1.1 and no execution occurs until confirmation
- **AND** cancellation leaves files and inventory unchanged
- **AND** confirmed execution uses the existing complete-target transaction and displays every affected scope
#### Scenario: Local file changes or unmanaged targets block upgrade
- **WHEN** any required installed target cannot pass the existing baseline or ownership checks
- **THEN** confirmation is blocked for the entire upgrade and no target is overwritten
- **AND** current installed state remains independently queryable

View file

@ -81,3 +81,16 @@ The plugin SHALL provide an explicit connection test for the draft Registry addr
#### Scenario: Invalid or unavailable source
- **WHEN** the address is invalid, public access is denied, the response is incompatible, or the deadline expires
- **THEN** the page shows an appropriate safe error without exposing response contents, credentials, or a false successful connection
### Requirement: Downloadable version pagination
The open-source versions endpoint SHALL accept optional `downloadableOnly`, defaulting to false. When true it SHALL preserve skill authorization, filter by existing installability before pagination, sort by publication time descending with nulls last and deterministic creation-time/id descending tie breakers, and return the filtered total. Default manager history SHALL preserve lifecycle grouping. Public version ordering SHALL be newest first.
#### Scenario: Filtered pages remain globally ordered
- **WHEN** published versions include unavailable downloads and publication order differs from repository creation order
- **THEN** the client receives only installable lifecycle versions, globally ordered before page slicing with the filtered total
- **AND** object-storage presence remains checked by the existing resolution/download flow
#### Scenario: Existing consumers omit the new parameter
- **WHEN** a management consumer omits downloadableOnly
- **THEN** its authorized lifecycle history remains available and no authentication policy changes

View file

@ -232,3 +232,57 @@
- [x] 29.2 Host Schema 缺省为正式域名,保留显式空白断开和自定义来源;同步双语文档及连接契约。
- [x] 29.3 配置边界测试、完整插件检查(299 项测试)、OpenSpec 校验及 0.2.1 干净候选默认来源验收通过(74 个文件一致、3 项浏览器检查);已有显式来源从 0.2.0 升级后的 11 个技能文件、metadata、inventory、历史会话逐字节保留及状态识别通过;空白保存后重启仍断开通过,自定义来源保存与重启保留以及页面连接测试通过。
- [ ] 29.4 完成 PR、六组 CI、专用 npm 发布及公开 0.2.1 包核对和安装验收;保留既有技能与对话。
## 30. 市场操作、详情阅读与范围确认
- [x] 30.1 卡片整体导航、独立安装管理操作、多范围选择和中文内建标签;未安装记录不占状态区。
- [x] 30.2 Markdown frontmatter 隐藏与阅读样式;移除全局可选模块重查按钮;详情与 Tab 有界缓存及旧凭据恢复。
- [x] 30.3 未指定范围的对话安装先展示选择 UI,保持显式范围安装和幂等写入保护。
- [x] 30.4 开源版本 API 增加可下载过滤与全局排序,分页和 total 保持一致;更新生成契约与 SaaS 同步说明。
- [x] 30.5 完成插件、后端、浏览器及真实模型对话验收,记录范围和未验证边界;本轮不部署 SaaS、不发布 npm。
## 31. 直接安装确认与输入框旁原生范围选项
- [x] 31.1 市场和对话降级入口使用紧凑确认弹窗,保留原安装事务、取消及焦点恢复。
- [x] 31.2 接入宿主 userQuestions 非计时问答;准确范围回答才提交,取消、异常、上下文变化与重复调用安全处理。
- [x] 31.3 补齐行为测试、真实 DSH 浏览器与真实模型验收,更新协议、文档和验证边界。
## 32. 安装弹窗向右扩展进度
- [x] 32.1 确认后保留弹窗,左侧固定信息、右侧精简进度;窄屏堆叠、减少动态和焦点处理。
- [x] 32.2 复用原幂等请求与操作读取,结束状态刷新去重,后台核验保留卡片内容。
- [x] 32.3 完成完整插件检查、真实 DSH 延迟安装浏览器验证和预览更新,记录证据。
## 33. 当前安装状态、范围与卸载入口
- [x] 33.1 当前库存优先,已完成历史回执退出安装控制区;已有安装优先选中对应范围。
- [x] 33.2 精简范围文案,详情和弹窗补齐目标明确的卸载/管理入口,保留原确认保护。
- [x] 33.3 用户级安装、范围切换、卸载、历史回执与再次安装的真实浏览器回归及完整插件检查。
## 34. 原地卸载与精简确认弹窗
- [x] 34.1 市场与已安装列表卸载原地打开弹窗;详情卸载不再跳转其他详情页。
- [x] 34.2 复用原核验、计划、幂等请求和恢复逻辑,统一 Radix 样式与精简内容。
- [x] 34.3 完整插件检查与真实浏览器取消、确认、范围、焦点、窄屏、真实文件及恢复预览验收。
## 35. 选定版本升级与完整更新验收
目标:修复详情切换到较新版本后缺少升级入口,保留已有最新版本升级和完整目标事务。假设:切换只读,不自动执行;只支持可靠比较的向上升级,不支持降级。影响插件 Host 类型化方法、版本详情、升级确认、库存缓存和文档,不修改 SaaS 或 CLI 事务规则。
- [x] 35.1 增加精确选定版本检查/计划接口,旧最新版接口保持兼容,固定版本不可退回 latest。
- [x] 35.2 详情升级入口与精简原地确认;相同、旧版、不可排序和不可访问版本明确反馈。
- [x] 35.3 Host 绑定、候选固定、取消、失败与 UI 状态测试;完整构建和 OpenSpec 校验。
- [x] 35.4 真实本地版本安装→非最新版定向升级→最新版升级,核对文件、metadata、库存、取消与保护行为;保留可人工 review 的预览。
## 36. 0.3.0 合并与 npm 发布
- [ ] 36.1 同步主线,独立评审和最终检查通过后合并 PR。
- [ ] 36.2 使用 `dsh-v0.3.0` tag 执行官方 npm 发布工作流,验证公开包完整性。
- [ ] 36.3 公开包执行官方隔离 Host 安装验收,保留现有人工预览和会话。

View file

@ -2,13 +2,13 @@
## 1. 当前结论
`0.2.0` 已经通过 [PR #977](https://github.com/iflytek/skillhub/pull/977) 合并并发布。本轮准备 `0.2.1`,仅调整缺省来源为 `https://skill.xfyun.cn`;候选、CI、npm 发布与公开包验收各自记录,尚未完成的项目不视为通过。
`0.2.1` 已通过 [PR #979](https://github.com/iflytek/skillhub/pull/979) 合并并发布;默认来源为 `https://skill.xfyun.cn`。本轮准备发布 `0.3.0` 开源交互改进,不以历史发布结果代替新代码验证。
2026-10-19 使用公开 npm `0.2.0` 的真实 Linux DSH 插件连接 `https://skill.xfyun.cn`,连接测试、目录、搜索、Markdown 和版本选择通过;选择 `global/scientella@20260724.052402` 实际安装到隔离项目,7 个文件及归档 fingerprint 一致,inventory 来源与目标正确,再搜索显示项目已安装旧版,DSH 发现成功。评论接口 404 按可选能力处理,安全审核 401 按匿名拒绝处理。本轮未调用模型,不据此声明技能实际执行或生产 Pod 已核验。该技能使用日期格式的非 SemVer 版本,版本比较返回 `blocked/unknown-order`,按既有策略不提供升级入口;不得把固定版本安装通过写成升级通过。升级阻断前后文件保持不变;真实卸载后 DSH 发现消失,inventory 与重新搜索的安装记录清空。公开 `0.2.0` 的正式来源基线共 9 项浏览器检查通过,无页面脚本异常。
`0.2.1` 本地构建、typecheck、lint、299 项测试(7158 次断言)及 OpenSpec 严格校验通过;配置测试覆盖缺省、空对象、显式空串/空白、自定义来源与初始化零网络请求。该结果不代替候选或公开包的真实 DSH 验收。
`0.2.1` 候选已通过官方安装器装入干净隔离 Profile,归档 SHA-256 为 `c80a9608d739e26d3ddeef42791e05645106e22895bf6039c3c4d493b539bc1a`,74 个交付文件逐字节一致。安装 patch 未设置 `registryUrl`,真实 Host 的 `getContext` 返回 `configured=true` 和正式域名;插件设置页默认输入与测试连接、目录与标签搜索、`scientella` Markdown 共 3 项浏览器检查通过。已有显式来源的升级保留通过:先用公开 `0.2.0` 安装 `scientella@20261017.164831`,再升级到 `0.2.1` 候选;11 个技能文件、metadata、inventory 及历史会话压缩文件逐字节保留,原 journal 为 completed,新搜索显示项目最新版本 present,DSH 发现正常,74 个候选文件再次核对一致。另在插件设置中显式保存空白后,真实 Host 返回 `configured=false`;重启后仍保留空白并保持断开。自定义来源保存、重启及页面连接测试也已通过;公开 `0.2.1` 包仍待发布后核验。
`0.2.1` 候选已通过官方安装器装入干净隔离 Profile,归档 SHA-256 为 `c80a9608d739e26d3ddeef42791e05645106e22895bf6039c3c4d493b539bc1a`,74 个交付文件逐字节一致。安装 patch 未设置 `registryUrl`,真实 Host 的 `getContext` 返回 `configured=true` 和正式域名;插件设置页默认输入与测试连接、目录与标签搜索、`scientella` Markdown 共 3 项浏览器检查通过。已有显式来源的升级保留通过:先用公开 `0.2.0` 安装 `scientella@20261017.164831`,再升级到 `0.2.1` 候选;11 个技能文件、metadata、inventory 及历史会话压缩文件逐字节保留,原 journal 为 completed,新搜索显示项目最新版本 present,DSH 发现正常,74 个候选文件再次核对一致。另在插件设置中显式保存空白后,真实 Host 返回 `configured=false`;重启后仍保留空白并保持断开。自定义来源保存、重启及页面连接测试也已通过;公开 `0.2.1` 的发布后安装与真实 SaaS 模型验收另见后续验收记录。
候选配置持久化实机补充:在真实设置页保存空地址后重启,输入仍为空且 Host `configured=false`;保存自定义本地 Registry 后重启,地址与 `configured=true` 保留,页面测试连接成功。测试后恢复正式来源以供预览。上述操作不修改已发布包,也不代替公开 `0.2.1` 的发布后核验。
@ -125,3 +125,22 @@
- 可复现的包外 Host 探针与限制见 [实机探针说明](../../../plugins/dsh/test/integration/README.md#搜索结果中的本地安装状态)。原始报告和截图保留在仓库外。上述宿主及页面用例未调用模型,市场内容为本地 HTTP 夹具,不代表真实 SaaS 或线上市场验收;未重新验证该新增能力在 Windows/macOS Host 上的行为。
- 另在同一候选包上使用真实 `astron-code-latest` 完成 3 轮对话、6 次模型请求,真实调用搜索 → 项目安装 → 再搜索。首次正确识别已有用户级 `meeting/meeting-notes@1.3.0`,没有将其他项目的 `demo/document-helper` 记录归入新项目;实际安装后,重新搜索正确识别新增项目级 `demo/document-helper@1.3.0`。安装操作完成,元数据及资源文件核验通过,DSH 已发现技能;模型回复、工具结果和完整对话均持久保存,未手工构造模型消息。浏览器核对了首次/复查卡片与当前广场状态,未重放写入。模型测试仍使用本地模拟市场,不能据此声明 SaaS 或真实线上技能执行效果已验收。
- 上述为 `0.2.0` 发布前的功能验收;其后已合并 PR #977 并发布 `0.2.0`,不修改已发布包。
## 0.3.0 交互与版本管理验收
本版默认来源保持 `https://skill.xfyun.cn`。以下新增功能使用独立开源后端和隔离技能验证,不代表 SaaS 已同步新增版本过滤接口,也不代表技能内容已执行。
| 范围 | 验证内容与结果 |
| --- | --- |
| 插件质量 | `cd plugins/dsh && bun run check`:构建、类型、Lint、357 项测试和 7414 次断言通过。 |
| 服务端契约 | `SkillQueryServiceTest` 53 项、`SkillControllerTest` 14 项通过;domain 全模块 699 项测试通过。HTTP 验证过滤后分页、总数和全局版本顺序,缺省保留管理历史。生成 OpenAPI 与 web 类型检查通过。 |
| 市场与详情 | 浏览器验证卡片操作不触发导航、中文标签、frontmatter 隐藏、短期详情缓存和窄屏布局,无页面脚本异常。 |
| 对话确认 | 真实 `astron-code-latest` 两轮、4 次请求触发原生安装范围问题;选择项目后真实安装并核对文件、fingerprint 和 DSH 发现。取消不写文件,缺少原生问答服务回退确认卡片。 |
| 安装与当前状态 | 10 项进度检查、7 项范围生命周期检查通过。确认后弹窗向右展开,延迟响应保持处理状态,重复提交与终态刷新去重。用户级安装、切换项目、卸载和再次安装均核对真实库存与文件。 |
| 原地卸载 | 10 项浏览器检查通过,覆盖市场、列表、详情入口、预检等待、取消、焦点恢复、窄屏和重复确认仅执行一次。真实文件移除后按原版本恢复。 |
| 版本升级 | 10 项浏览器检查通过:用户和项目固定版本安装、精确升级到非最新版、最新版升级、取消无写入、同版不重复、旧版禁止降级、本地修改阻止整个升级。核对两处 metadata、文件内容和 SHA-256。 |
自动化回归位于 `plugins/dsh/test/`,包括 `native-scope-choice.test.ts`、`install-control-state.test.tsx`、`installation-progress.test.tsx`、`removal-confirmation.test.tsx`、`upgrade-presentation.test.tsx`、`prepare-upgrade.test.ts` 与 `update-check.test.ts`。详细运行报告和截图保留在仓库外,不作为发布包内容。
浏览器与真实模型验收环境为 Linux 官方 DSH `0.2.0-rc.2`;macOS/Windows 原生 Host 兼容性由发布 CI 单独验证。升级故障恢复依赖现有事务自动化测试,本次浏览器结果不扩大为所有故障环境验收。发布状态以实际 tag、CI 和公开 npm 包为准。

View file

@ -169,3 +169,15 @@ The plugin Host calls anonymous Registry HTTP APIs; local file transactions stay
The plugin uses independent `dsh-v<version>` tags. `check-dsh-plugin.yml` runs normal clean-install checks on Linux/macOS/Windows × Node 24.2.0/current Node 24, plus an isolated retained-Cordis baseline regression. `release-dsh-plugin.yml` is manual and defaults to candidate-only operation. Publishing requires an approved version, publishable manifest, candidate acceptance and explicit release authorization; configured environment protection rules must also be satisfied. Version updates are explicit commits; npm publication uses the dedicated workflow after release authorization.
See the [release guide](../../openspec/changes/add-dsh-native-plugin/release-guide.md) and [release notes](../../openspec/changes/add-dsh-native-plugin/release-notes.md). OpenSpec and integration links refer to the source repository; those documents are not included in the tarball.
### Open-source interaction update (unreleased)
Market cards open details and offer separate installation-management actions. Multiple installed scopes require a selected target. Conversation installs without an explicit scope use the Host input-adjacent question UI to choose current project, current user, or cancel before any installation write. Hosts without an answerer retain a pending card that opens a compact confirmation dialog. Market install actions open the same dialog without detail-page navigation. A trusted project is preselected when available, but never submitted automatically. A later explicit user scope may start a new call. Details omit complete leading frontmatter and reuse public reads for 60 seconds without bypassing write validation. The versions tab requests `downloadableOnly=true`, requiring the updated open-source backend; SaaS synchronization and acceptance remain separate.
After confirmation, the installation dialog expands to the right, keeping the skill, version, and scope on the left and concise real progress on the right. Narrow screens stack the sections. Progress remains indeterminate until completion; closing the dialog does not cancel a submitted installation.
Installation controls use current inventory rather than historical success receipts. An existing installation selects its scope and offers removal. Project-only scope uses the project directory; all-project scope uses the current user directory on this Host. Changing scope never installs automatically; a removed installation may be explicitly installed again.
Removal from market cards, installed rows, and detail controls opens an in-place confirmation. It shows the skill, version, selected scope, and impact. Opening only prepares a plan; confirmation executes it. Cancel and Escape never remove files, and existing expiry, identity, and local-file checks still apply.
Version selection is read-only. A verifiable newer selected version offers an explicit exact-version upgrade; equal versions do not upgrade again, older versions cannot downgrade, and unorderable versions are blocked. Confirmation lists all installed targets that the shared transaction updates. Latest-update checks remain available, with in-place confirmation, local-change protection, and recovery.

View file

@ -169,3 +169,15 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh
插件独立使用 `dsh-v<version>` tag,不复用 CLI 或服务镜像发布。`check-dsh-plugin.yml` 配置 Linux/macOS/Windows × Node 24.2.0/当前 Node 24 的普通干净安装检查,并额外保留旧 Cordis 组合的隔离 Host 回归;`release-dsh-plugin.yml` 手动触发,默认只产出候选包。发布需正式版本、可发布 manifest、候选验收及明确发布授权;配置了环境保护规则时还须完成相应审批。版本更新需显式提交,npm 发布需明确授权后运行专用工作流。
具体部署、发布后检查与回退清单见 [发布指南](../../openspec/changes/add-dsh-native-plugin/release-guide.md),变化见 [发布说明](../../openspec/changes/add-dsh-native-plugin/release-notes.md)。上述 OpenSpec 与测试链接面向源码仓库,不包含在 npm tarball 中。
### 开源交互更新(待发布)
市场卡片支持点击进入详情及独立安装、卸载、检查更新/升级操作;多范围记录先选择管理目标。未指定范围的对话安装优先在 DSH 输入框旁显示原生范围选项,选择并提交才安装;可取消。无宿主问答能力时降级为待确认卡片,直接打开紧凑确认弹窗。市场卡片的安装按钮也直接打开确认弹窗;有效项目默认预选项目,但不自动提交。用户后续明确范围也可发起新调用。详情隐藏完整首部元数据,保留安全 Markdown;公开读取缓存 60 秒,写入仍重新核验。版本 Tab 请求 `downloadableOnly=true`,需要本次开源后端契约;SaaS 尚需单独同步和验收。
安装确认后弹窗保留并向右展开:左侧固定技能、版本和范围,右侧展示简洁的实际进度与结果;窄屏上下排列。处理中不模拟百分比,关闭窗口不取消已提交的安装。
安装管理以当前库存为准,历史安装成功不代表目前仍已安装。已有记录时默认选中对应范围,并提供卸载入口;“仅此项目”对应项目目录,“所有项目”对应当前 Host 的当前用户级目录。切换范围不会自动安装;卸载后可再次明确安装。
市场卡片、已安装列表和详情中的卸载可直接打开确认弹窗,不再为卸载跳转到另一详情页。确认框保留技能、版本、所选范围和影响说明;打开仅核验,确认才执行。取消与 Esc 不卸载,过期计划和异常安装仍由原事务校验阻断。
切换版本只查看内容,不会自动更新。已安装时选择可核验的较新版本,可“升级到该版本”;同版本不重复升级,旧版本不支持降级,不可排序版本阻断升级。确认框列出实际同步更新的全部安装范围。检查最新版仍可从市场或已安装列表使用;升级原地确认,保留本地修改保护与恢复。

View file

@ -37,6 +37,7 @@
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-tool": "0.2.0-rc.2",
"@deepseek-ai/dsh-typert-generator": "0.2.0-rc.2",
"@deepseek-ai/dsh-user-questions": "0.2.0-rc.2",
"@types/bun": "1.3.13",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
@ -56,7 +57,11 @@
"@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2",
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
"@deepseek-ai/dsh-user-questions": "0.2.0-rc.2",
},
"optionalPeers": [
"@deepseek-ai/dsh-user-questions",
],
},
},
"overrides": {
@ -208,6 +213,8 @@
"@deepseek-ai/dsh-user-approval": ["@deepseek-ai/dsh-user-approval@0.2.0-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.2.0-rc.2.tgz", { "dependencies": { "@deepseek-ai/schemastery": "~3.18.4" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/dsh-agent": "0.2.0-rc.2", "@deepseek-ai/dsh-brand": "0.2.0-rc.2", "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", "@deepseek-ai/dsh-llm": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", "@deepseek-ai/dsh-session": "0.2.0-rc.2", "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2" } }, "sha512-fZMfDp4IeWZMwAKEQkuAYCiGRrwoVWNObvAbpdmQNUavjfYCtwwduWqYQz+5lMQq8vENdczBj7cxnO/pOQ1oZQ=="],
"@deepseek-ai/dsh-user-questions": ["@deepseek-ai/dsh-user-questions@0.2.0-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-user-questions/-/dsh-user-questions-0.2.0-rc.2.tgz", { "dependencies": { "@deepseek-ai/schemastery": "~3.18.4", "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/dsh-agent": "0.2.0-rc.2", "@deepseek-ai/dsh-llm": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", "@deepseek-ai/dsh-session": "0.2.0-rc.2", "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2" } }, "sha512-NrfPt/DBCbkK/+sSSaHT3kG5lA71mnpBbxTDsL9/eS/hVntT+IdthVIZy9tPiuBxCnq1N6iPO874zHlAYa6qCQ=="],
"@deepseek-ai/dsh-util-crypto": ["@deepseek-ai/dsh-util-crypto@0.2.0-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-util-crypto/-/dsh-util-crypto-0.2.0-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4" } }, "sha512-5OvvmepY69uXTagUMlnoHqg7euI4ZMifLgfdV8xZO20OslWGGiRCkJbZRyDtcJG9GsXWz9UimfWmUu52zKXJ5w=="],
"@deepseek-ai/dsh-util-time": ["@deepseek-ai/dsh-util-time@0.2.0-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-util-time/-/dsh-util-time-0.2.0-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4" } }, "sha512-x+b6PJ7BQ5HnNs0wa+eojmwkveR5X77VzhfKJ8p7ndB0TAdFGBshji30nxNjn/8aNWMdE90gsIvbtazsrGZn+w=="],
@ -586,6 +593,8 @@
"@deepseek-ai/dsh-settings/@deepseek-ai/cosmokit": ["@deepseek-ai/cosmokit@1.8.5", "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", {}, "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=="],
"@deepseek-ai/dsh-user-questions/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
"@deepseek-ai/schemastery/@deepseek-ai/cosmokit": ["@deepseek-ai/cosmokit@1.8.5", "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", {}, "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.5.0", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ=="],

View file

@ -1,6 +1,6 @@
{
"name": "@astron-team/skillhub-dsh",
"version": "0.2.1",
"version": "0.3.0",
"private": false,
"type": "module",
"license": "Apache-2.0",
@ -82,7 +82,8 @@
"@deepseek-ai/dsh-session-query": "0.2.0-rc.2",
"@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2",
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2"
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
"@deepseek-ai/dsh-user-questions": "0.2.0-rc.2"
},
"dependencies": {
"@deepseek-ai/schemastery": "3.18.4",
@ -123,7 +124,8 @@
"@deepseek-ai/cordis": "4.0.5",
"@deepseek-ai/cordis-plugin-loader": "1.0.6",
"@deepseek-ai/cordis-plugin-include": "1.0.10",
"@deepseek-ai/cordis-plugin-timer": "1.1.7"
"@deepseek-ai/cordis-plugin-timer": "1.1.7",
"@deepseek-ai/dsh-user-questions": "0.2.0-rc.2"
},
"engines": {
"node": ">=24.2.0 <25"
@ -146,5 +148,10 @@
"@deepseek-ai/cordis-plugin-loader": "1.0.6",
"@deepseek-ai/cordis-plugin-include": "1.0.10",
"@deepseek-ai/cordis-plugin-timer": "1.1.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-user-questions": {
"optional": true
}
}
}

View file

@ -0,0 +1,13 @@
const builtInLabels: Record<string, string> = {
official: '官方', 'ai-competition': '人工智能竞赛', 'ai competition': '人工智能竞赛', aicompetition: '人工智能竞赛',
'aicomp-2026': '2026 人工智能竞赛', coding: '编程', automation: '自动化',
productivity: '效率工具', entertainment: '娱乐', image: '图像',
communication: '沟通协作', health: '健康',
};
/** Translate only known built-ins; the original slug remains the API filter. */
export function catalogLabel(item: { slug: string; displayName?: string }) {
const name = item.displayName?.trim() || undefined;
if (name && /\p{Script=Han}/u.test(name)) return name;
return builtInLabels[item.slug.toLowerCase()] ?? builtInLabels[name?.toLowerCase() ?? ''] ?? name ?? item.slug;
}

View file

@ -1,3 +1,9 @@
import { UpgradeControl } from './upgrade-control.js';
import { RemovalControl } from './removal-control.js';
import { SkillInstallDialog } from './install-dialog.js';
import { readCacheOptions } from './read-cache.js';
import { catalogLabel } from './catalog-label.js';
import { MarketCardActions } from './market-card-actions.js';
import { CardSkeletons, CatalogSkeleton, DetailSkeleton } from './loading-layout.js';
import { UiAvailabilityNotice, type UiAvailability } from './ui-availability.js';
import { NativeControlStyles } from './native-control-styles.js';
@ -14,7 +20,7 @@ import type { InstallationServices } from './install-control.js';
import { SelectField, Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from './ui.js';
import { Icon, SkillAvatar, SkillCard, SkillMetrics } from './skill-card.js';
import type { InfiniteData } from '@tanstack/react-query';
import type { CatalogPageResult, SkillList } from '../host/types.js';
import type { CatalogPageResult, SkillList, InstalledSkillList } from '../host/types.js';
import { summarizeInstallation } from '../host/installation-summary.js';
import { InstallationStatus } from './installation-status.js';
import '../../lib/typert.remote-client.js';
@ -47,6 +53,9 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
const queryClient = useQueryClient();
const entries = useSyncExternalStore(availability.subscribe, availability.getSnapshot);
const [sourceOpen, setSourceOpen] = useState(false);
const [upgradeCandidate, setUpgradeCandidate] = useState<{ item: InstalledSkillList['items'][number]; revision: string; source: string } | null>(null);
const [removalCandidate, setRemovalCandidate] = useState<{ item: InstalledSkillList['items'][number]; revision: string; source: string } | null>(null);
const [installCandidate, setInstallCandidate] = useState<{ item: Item; revision: string } | null>(null);
const [tab, setTab] = useState(navigation.tab);
const [installedOrigin, setInstalledOrigin] = useState(navigation.installedOrigin);
const [draft, setDraft] = useState(navigation.draft);
@ -66,7 +75,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
enabled: !!installed && !!revision && !!context.data?.configured && tab === 'browse', retry: false,
queryFn: async ({ signal }) => unwrap(await installed!.list(revision!, signal)) });
const label = labelSelection && labelSelection.sourceRevision === revision ? labelSelection.slug : '';
const labels = useQuery({ queryKey: ['skillhub-labels', revision], enabled: !!context.data?.configured, retry: false,
const labels = useQuery({ ...readCacheOptions, queryKey: ['skillhub-labels', revision], enabled: !!context.data?.configured, retry: false,
queryFn: async ({ signal }) => unwrap(await remote.listLabels(revision!, signal)) });
useLayoutEffect(() => {
if (revision && labelSelection && labelSelection.sourceRevision !== revision) {
@ -77,6 +86,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
const installedNavigation = installed && revision ? installedNavigationFor(navigation, installed.sessionId, revision) : undefined;
const sourceMatches = !selected || revision === navigation.sourceRevision;
const listing = useInfiniteQuery({
...readCacheOptions,
queryKey: ['skillhub-catalog', revision, query, sort, label], enabled: !!context.data?.configured && tab === 'browse',
initialPageParam: 0, retry: false,
queryFn: async ({ signal, pageParam }) => unwrap(await (label ? remote.listCatalogByLabel(revision!, query, pageParam, sort, label, signal)
@ -87,6 +97,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
const items = Array.from(new Map((pages?.flatMap(page => page.items) ?? []).map(item => [`${item.namespace}/${item.slug}`, item])).values());
const limited = pages?.[0]?.mode === 'limited';
const detail = useQuery({
...readCacheOptions,
queryKey: ['skillhub-detail', revision, selected?.namespace, selected?.slug, selected?.latestVersion],
enabled: !!selected && !!context.data?.configured && sourceMatches,
queryFn: async ({ signal }) => unwrap(await remote.getSkillDetail(revision!, selected!.namespace, selected!.slug, selected!.latestVersion, signal)), retry: false,
@ -98,6 +109,17 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
else lastCard.current?.focus({ preventScroll: true });
}, [selected, navigation, tab]);
function back() { navigation.selected = null; setSelected(null); }
function openSkill(item: Item, button?: HTMLButtonElement) {
lastSelected.current = item;
if (button) lastCard.current = button;
navigation.scrollTop = scroller.current?.scrollTop ?? 0;
navigation.selected = item; navigation.sourceRevision = revision!;
setSelected(item);
}
function manageSkill(item: NonNullable<typeof inventory.data>['items'][number], intent: 'remove' | 'upgrade') {
if (intent === 'remove') { if (revision && context.data) setRemovalCandidate({ item, revision, source: context.data.registryUrl }); return; }
if (revision && context.data) setUpgradeCandidate({ item, revision, source: context.data.registryUrl });
}
function error(message: string, retry: () => void) {
return <div role="alert" className="astron-sh-error"><p>{message}</p><Button onClick={retry}>重试</Button></div>;
}
@ -124,6 +146,19 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
}
return <section ref={scroller} className="skillhub-controls astron-sh-catalog" aria-label="SkillHub 技能广场">
<NativeControlStyles /><UiAvailabilityNotice availability={availability} />
{upgradeCandidate && installed && <UpgradeControl key={`${installed.sessionId}:${upgradeCandidate.revision}:${upgradeCandidate.item.itemKey}`} presentation="dialog" eligible
services={installed} sourceRevision={upgradeCandidate.revision} source={upgradeCandidate.source} item={upgradeCandidate.item} onClosed={() => setUpgradeCandidate(null)} />}
{removalCandidate && installed && <RemovalControl key={`${installed.sessionId}:${removalCandidate.revision}:${removalCandidate.item.itemKey}`} presentation="dialog"
services={installed} sourceRevision={removalCandidate.revision} source={removalCandidate.source} item={removalCandidate.item}
eligible={['present', 'missing'].includes(removalCandidate.item.state)} onClosed={() => setRemovalCandidate(null)} />}
{installCandidate && installation && <SkillInstallDialog client={queryClient} identity={JSON.stringify([installCandidate.revision, installCandidate.item.namespace, installCandidate.item.slug, installCandidate.item.latestVersion])}
services={installation} initiatedFrom="plaza" close={() => setInstallCandidate(null)} onManage={item => { setInstallCandidate(null); manageSkill(item, 'remove'); }} load={async signal => {
const source = unwrap(await remote.getContext());
if (source.sourceRevision !== installCandidate.revision) throw new Error('来源已变化,请重新选择。');
const detail = unwrap(await remote.getSkillDetail(installCandidate.revision, installCandidate.item.namespace, installCandidate.item.slug, installCandidate.item.latestVersion, signal));
return { detail, sourceRevision: source.sourceRevision };
}} />}
<header className="astron-sh-catalog-header"><div className="astron-sh-location"><Icon name="grid" /><strong>SkillHub</strong><span>/</span><span>{selected ? '技能详情' : '技能广场'}</span></div>
<div className="astron-sh-toolbar-actions"><Button variant="ghost" className="astron-sh-icon-button" aria-label="查看当前来源" title={context.data?.registryUrl || '尚未配置来源'} onClick={() => setSourceOpen(value => !value)}><Icon name="info" /></Button>
{(installation || installed) && <Button variant="ghost" className="astron-sh-icon-button" aria-label="返回会话" title="关闭 SkillHub,返回会话" onClick={() => { if (!selected && tab === 'browse') navigation.scrollTop = scroller.current?.scrollTop ?? 0; onReturn(); }}><Icon name="close" /></Button>}</div>
@ -141,20 +176,22 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
<p>@{selected.namespace}/{selected.slug} · {selected.latestVersion}</p></div></div>
<p className="astron-sh-detail-summary">{sourceMatches ? detail.data?.summary ?? selected.summary : selected.summary}</p>
{!sourceMatches ? error('来源已变化,请返回技能广场重新选择。', back)
: detail.isPending || detail.isFetching ? <DetailSkeleton />
: detail.isPending ? <DetailSkeleton />
: detail.isError ? error(detail.error.message, () => { void detail.refetch(); })
: <><SkillMetrics item={detail.data} /><div className="astron-sh-detail-layout"><article className="astron-sh-detail-main astron-sh-documentation" aria-label="技能说明">
<DetailContent detail={detail.data} modes={context.data.detailTabs} remote={remote} retry={() => { void detail.refetch(); }}
cacheIdentity={JSON.stringify([installation?.sessionId ?? installed?.sessionId, revision, selected.namespace, selected.slug, selected.latestVersion])}
onContextStale={() => { void detail.refetch(); }}
onSelectVersion={version => { const next = { ...selected, latestVersion: version }; navigation.selected = next; setSelected(next); }} />
</article><aside className="astron-sh-detail-aside">
{installation ? <InstallControl key={installation.sessionId} detail={detail.data} sourceRevision={revision!} services={installation} initiatedFrom="plaza" />
</article><aside aria-label="选择安装范围并安装" className="astron-sh-detail-aside">
{installation ? <InstallControl key={installation.sessionId} detail={detail.data} sourceRevision={revision!} services={installation} initiatedFrom="plaza" onManage={item => manageSkill(item, 'remove')} onContextStale={() => { void detail.refetch(); }} />
: <div className="astron-sh-empty"><p>请先打开一个 DSH 会话,再选择安装范围。</p><Button onClick={onReturn}>前往会话</Button></div>}
</aside></div></>}
</> : <>
<Tabs value={tab} onValueChange={changeTab}><TabsList className="astron-sh-directory-tabs" aria-label="技能目录"><TabsTrigger value="browse">浏览技能</TabsTrigger><TabsTrigger value="installed" onClick={() => { navigation.installedOrigin = undefined; setInstalledOrigin(undefined); }}>已安装</TabsTrigger></TabsList>
<TabsContent value="installed">{installedOrigin && (installedOrigin.sessionId !== installed?.sessionId || installedOrigin.sourceRevision !== revision)
? <p role="alert">会话或来源已变化,请点击“已安装”查看当前上下文的记录。</p>
: installed ? <InstalledSkills key={`${installed.sessionId}:${revision}`} services={installed} navigation={installedNavigation!} source={context.data.registryUrl} sourceRevision={revision!} remote={remote} modes={context.data.detailTabs} scrollContainer={scroller} />
: installed ? <InstalledSkills key={`${installed.sessionId}:${revision}`} services={installed} onRemove={item => manageSkill(item, 'remove')} onUpgrade={item => manageSkill(item, 'upgrade')} navigation={installedNavigation!} source={context.data.registryUrl} sourceRevision={revision!} remote={remote} modes={context.data.detailTabs} scrollContainer={scroller} />
: <div className="astron-sh-empty"><p>请先打开一个 DSH 会话,再查看该用户和项目的安装记录。</p><Button onClick={onReturn}>前往会话</Button></div>
}</TabsContent><TabsContent value="browse">
<form className="astron-sh-search-form" onSubmit={event => { event.preventDefault(); search(draft.trim()); }}>
@ -163,22 +200,28 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
</form>
{(label || !!labels.data?.items.length) && <div className="astron-sh-label-filter" role="group" aria-label="技能标签">
<span>标签</span><Button size="sm" variant="ghost" aria-pressed={!label} onClick={() => changeLabel('')}>全部</Button>
{labels.data?.items.map(item => <Button key={item.slug} size="sm" variant="ghost" aria-pressed={label === item.slug} onClick={() => changeLabel(item.slug)}>{item.displayName || item.slug}</Button>)}
{labels.data?.items.map(item => <Button key={item.slug} size="sm" variant="ghost" aria-pressed={label === item.slug} onClick={() => changeLabel(item.slug)}>{catalogLabel(item)}</Button>)}
{label && !labels.data?.items.some(item => item.slug === label) && <Button size="sm" variant="ghost" aria-pressed="true" onClick={() => changeLabel('')}>{label} · 清除</Button>}
</div>}
{labels.isError && <p className="astron-sh-label-note" role="status">标签暂时无法读取。<Button size="sm" variant="ghost" disabled={labels.isFetching} onClick={() => { void labels.refetch(); }}>重试标签</Button>{label && <Button size="sm" variant="ghost" onClick={() => changeLabel('')}>清除标签筛选</Button>}</p>}
<div className="astron-sh-result-bar"><p role="status">{pages ? `共匹配 ${pages[0]!.total} 项,当前显示 ${items.length} 项。` : '技能目录'}</p>
{!limited && <label>排序<SelectField label="技能排序" value={sort} onValueChange={next => { const value = next as Sort; resetListing(query, value); navigation.sort = value; setSort(value); }} options={[{ value: 'newest', label: '最新发布' }, { value: 'downloads', label: '下载量' }, { value: 'rating', label: '评分' }]} /></label>}
</div>
{installed && inventory.isPending && !inventory.data && <p className="astron-sh-label-note" role="status">正在核对本地安装状态,完成后可安装或管理技能。</p>}
{installed && !inventory.isFetching && (inventory.isError || !inventory.data || !inventory.data.complete || inventory.data.unavailable.length > 0) && <p className="astron-sh-label-note" role="status">部分本地安装状态暂时无法核实,安装和管理操作暂不可用。<Button size="sm" variant="ghost" onClick={() => { void inventory.refetch(); }}>重新核对</Button></p>}
{limited && <p role="status" className="astron-sh-source">当前来源仅支持首批搜索,不支持分页与排序。可输入更具体的关键词缩小范围。</p>}
{listing.isPending ? <CardSkeletons /> : listing.isError && !pages ? error(listing.error.message, () => { void listing.refetch(); }) : <>
{items.length === 0 ? <p className="astron-sh-empty">{listing.hasNextPage ? '此批没有可展示的已发布技能,可继续加载。' : '没有找到匹配的技能,请尝试其他关键词。'}</p>
: <div className="astron-sh-card-grid">{items.map(item => <SkillCard key={`${item.namespace}/${item.slug}`} item={item} version={item.latestVersion}
status={<InstallationStatus pending={!!installed && inventory.isFetching}
installation={inventory.data && !inventory.isError && !inventory.isFetching ? summarizeInstallation(item, inventory.data, new Date(inventory.dataUpdatedAt).toISOString()) : undefined} />}
status={inventory.data && !inventory.isError
&& inventory.data.items.some(record => record.namespace === item.namespace && record.slug === item.slug)
? <InstallationStatus hideEmpty installation={summarizeInstallation(item, inventory.data, new Date(inventory.dataUpdatedAt).toISOString())} /> : undefined}
actions={<MarketCardActions records={inventory.data?.items.filter(record => record.namespace === item.namespace && record.slug === item.slug) ?? []}
services={installed} sourceRevision={revision!}
verified={!!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length}
onInstall={() => revision && setInstallCandidate({ item, revision })} onManage={manageSkill} />}
ref={node => { if (lastSelected.current?.namespace === item.namespace && lastSelected.current?.slug === item.slug) lastCard.current = node; }}
onOpen={event => { lastSelected.current = item; lastCard.current = event.currentTarget; navigation.scrollTop = scroller.current?.scrollTop ?? 0;
navigation.selected = item; navigation.sourceRevision = revision!; setSelected(item); }} />)}</div>}
onOpen={event => openSkill(item, event.currentTarget)} />)}</div>}
{listing.isRefetchError && <div role="alert" className="astron-sh-error"><p>刷新失败,当前保留上次加载的结果,尚未确认是否为最新内容。{listing.error.message}</p><Button disabled={listing.isFetching} onClick={() => { void listing.refetch(); }}>重新刷新</Button></div>}
{listing.isFetchNextPageError && <p role="alert">下一批暂时无法读取,已加载的技能保留。请重试。</p>}
{listing.hasNextPage && <div className="astron-sh-load-more"><span>已显示 {items.length} 项</span><Button disabled={listing.isFetching} onClick={() => { void listing.fetchNextPage(); }}>{listing.isFetchingNextPage ? '正在加载…' : listing.isFetchNextPageError ? '重新加载下一批' : '加载更多'}</Button></div>}

View file

@ -26,8 +26,8 @@ function value<T>(result: RemoteResult<T>): T {
if (result.error.code === 'skillhub/context-stale') throw new StaleDetailError('这条搜索记录的引用已失效,需重新核验当前会话与来源。');
throw new Error('详情暂时无法读取,请稍后重试。');
}
export function ConversationDetail({ reference, callId, originalSource, services, close, browseVersions }: {
reference: string; callId: string; originalSource: string; services: SearchViewServices; close: () => void; browseVersions?: () => void;
export function ConversationDetail({ reference, callId, originalSource, services, close, browseVersions, allowHistoricalReopen = true }: {
reference: string; callId: string; originalSource: string; services: SearchViewServices; close: () => void; browseVersions?: () => void; allowHistoricalReopen?: boolean;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useLayoutEffect(() => {
@ -40,14 +40,14 @@ export function ConversationDetail({ reference, callId, originalSource, services
style={{ margin: '0 0 0 auto', height: '100dvh', maxHeight: '100dvh', width: 'min(720px, 100vw)', maxWidth: '100vw', boxSizing: 'border-box', border: '1px solid #8885', padding: 24, overflowY: 'auto', font: 'inherit', lineHeight: 1.6, overflowWrap: 'anywhere' }}>
<NativeControlStyles />
<button autoFocus onClick={close} style={{ float: 'right', minHeight: 40 }} aria-label="关闭技能详情">关闭</button>
<QueryClientProvider client={services.client}><Content reference={reference} callId={callId} originalSource={originalSource} services={services} browseVersions={browseVersions} /></QueryClientProvider>
<QueryClientProvider client={services.client}><Content reference={reference} callId={callId} originalSource={originalSource} services={services} browseVersions={browseVersions} allowHistoricalReopen={allowHistoricalReopen} /></QueryClientProvider>
</dialog>;
}
function Content({ reference, callId, originalSource, services, browseVersions }: { reference: string; callId: string; originalSource: string; services: SearchViewServices; browseVersions?: () => void }) {
function Content({ reference, callId, originalSource, services, browseVersions, allowHistoricalReopen }: { reference: string; callId: string; originalSource: string; services: SearchViewServices; browseVersions?: () => void; allowHistoricalReopen: boolean }) {
const [reopening, setReopening] = useState(false);
const query = useQuery({ queryKey: ['skillhub-conversation-detail', services.installation.sessionId, callId, reference, reopening], retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false,
queryFn: async ({ signal }) => {
const [context, detail] = await Promise.all([services.remote.getContext(), reopening ? services.reopenDetail(callId, reference, signal) : services.openDetail(reference, signal)]);
const [context, detail] = await Promise.all([services.remote.getContext(), reopening && allowHistoricalReopen ? services.reopenDetail(callId, reference, signal) : services.openDetail(reference, signal)]);
return { context: value(context), detail: value(detail) };
},
});
@ -55,8 +55,8 @@ function Content({ reference, callId, originalSource, services, browseVersions }
if (query.isError) return <div role="alert">
<p>原搜索来源:{originalSource}</p>
{query.error instanceof StaleDetailError
? <><p>{reopening ? '无法在当前会话与来源中重新核验这条记录,请重新搜索技能。' : query.error.message}</p>
{!reopening && <button onClick={() => setReopening(true)}>重新查看详情</button>}</>
? <><p>{reopening || !allowHistoricalReopen ? '无法在当前会话与来源中重新核验这条记录,请重新搜索技能。' : query.error.message}</p>
{!reopening && allowHistoricalReopen && <button onClick={() => setReopening(true)}>重新查看详情</button>}</>
: <><p>{query.error.message}</p><button onClick={() => { void query.refetch(); }}>重试</button></>}
</div>;
const { context, detail } = query.data;
@ -64,8 +64,8 @@ function Content({ reference, callId, originalSource, services, browseVersions }
{reopening && <p role="status">已重新核验详情。安装需要另行确认。</p>}
<div className="astron-sh-detail-heading"><SkillAvatar name={detail.title} slug={detail.slug} namespace={detail.namespace} /><div><h2>{detail.title}</h2><p>@{detail.namespace}/{detail.slug} · {detail.version}</p></div></div><p>{detail.summary}</p><SkillMetrics item={detail} />
<Acknowledgment key={detail.detailToken} reference={detail.selectionRef} token={detail.detailToken} acknowledge={services.acknowledgeDetail} />
<InstallControl detail={detail} sourceRevision={context.sourceRevision} services={services.installation} initiatedFrom="conversation" />
<article className="astron-sh-documentation"><DetailContent detail={detail} modes={context.detailTabs} remote={services.remote} retry={() => { void query.refetch(); }} onSelectVersion={browseVersions} versionActionLabel="前往广场选择版本" /></article>
<InstallControl detail={detail} sourceRevision={context.sourceRevision} services={services.installation} initiatedFrom="conversation" onContextStale={() => { void query.refetch(); }} />
<article className="astron-sh-documentation"><DetailContent detail={detail} modes={context.detailTabs} remote={services.remote} retry={() => { void query.refetch(); }} cacheIdentity={JSON.stringify([services.installation.sessionId, context.sourceRevision, detail.namespace, detail.slug, detail.version])} onContextStale={() => { void query.refetch(); }} onSelectVersion={browseVersions} versionActionLabel="前往广场选择版本" /></article>
<details className="astron-sh-conversation-provenance"><summary>来源信息</summary><p>原搜索来源:{originalSource}</p><p>当前详情来源:{context.registryUrl}</p></details>
</>;
}

View file

@ -3,26 +3,21 @@ import rehypeSanitize from 'rehype-sanitize';
import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol';
import type { SkillDetail, SkillHubConfig } from '../host/types.js';
import { DetailTabs } from './detail-tabs.js';
import { stripMarkdownFrontmatter } from './markdown-content.js';
import { MarkdownStyles } from './markdown-styles.js';
import '../../lib/typert.remote-client.js';
export function DetailContent({ detail, modes, remote, retry, onSelectVersion, versionActionLabel }: {
export function DetailContent({ detail, modes, remote, retry, onSelectVersion, versionActionLabel, cacheIdentity, onContextStale }: {
detail: SkillDetail; modes: SkillHubConfig['detailTabs'];
remote: TypertRemoteNamespaceMap['skillhub']; retry: () => void; onSelectVersion?(version: string): void; versionActionLabel?: string;
remote: TypertRemoteNamespaceMap['skillhub']; retry: () => void; onSelectVersion?(version: string): void; versionActionLabel?: string; cacheIdentity?: string; onContextStale?(): void;
}) {
const pendingModules = (['versions', 'securityAudit', 'reviews'] as const)
.filter(module => modes[module] === 'auto' && detail.tabs[module] === 'unknown')
.map(module => ({ versions: '版本', securityAudit: '安全审核', reviews: '评论' })[module]);
return <>
{pendingModules.length > 0 && <div role="status">
<p>{pendingModules.join('、')}是否可读取尚未确认,不代表没有内容。</p>
<button onClick={retry}>重新检查可选内容</button>
</div>}
<DetailTabs key={detail.detailToken} detail={detail} modes={modes} remote={remote} onSelectVersion={onSelectVersion} versionActionLabel={versionActionLabel}>
<MarkdownStyles />
<DetailTabs key={cacheIdentity ?? `${detail.namespace}/${detail.slug}/${detail.version}`} cacheIdentity={cacheIdentity} onContextStale={onContextStale} detail={detail} modes={modes} remote={remote} onSelectVersion={onSelectVersion} versionActionLabel={versionActionLabel}>
{detail.documentation.state === 'error' ? <div role="alert"><p>当前版本的说明暂时不可读取。</p><button onClick={retry}>重试</button></div>
: <ReactMarkdown rehypePlugins={[rehypeSanitize]} skipHtml components={{
: <div className="astron-sh-markdown"><ReactMarkdown rehypePlugins={[rehypeSanitize]} skipHtml components={{
img: ({ alt }) => <span>{alt ? `[图片:${alt}]` : '[图片]'}</span>,
a: ({ href, children }) => href && /^https?:\/\//i.test(href) ? <a href={href} target="_blank" rel="noopener noreferrer">{children}</a> : <span>{children}</span>,
pre: ({ children }) => <pre style={{ overflowX: 'auto', padding: 12, background: '#8881' }}>{children}</pre>,
}}>{detail.documentation.content}</ReactMarkdown>}
}}>{stripMarkdownFrontmatter(detail.documentation.content)}</ReactMarkdown></div>}
</DetailTabs></>;
}

View file

@ -1,20 +1,27 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent, Card, Button } from './ui.js';
import { SkillAvatar, Icon } from './skill-card.js';
import { readableDate, validRating, validCount } from './skill-presentation.js';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol';
import type { SkillDetail, SkillHubConfig } from '../host/types.js';
import type { ReactNode } from 'react';
import '../../lib/typert.remote-client.js';
import { readCacheOptions } from './read-cache.js';
type Remote = TypertRemoteNamespaceMap['skillhub'];
type Tab = 'details' | 'versions' | 'securityAudit' | 'reviews';
const names: Record<Tab, string> = { details: '详情', versions: '版本', securityAudit: '安全审核', reviews: '评论' };
export function DetailTabs({ detail, modes, remote, children, onSelectVersion, versionActionLabel }: {
detail: SkillDetail; modes: SkillHubConfig['detailTabs']; remote: Remote; children: ReactNode; onSelectVersion?(version: string): void; versionActionLabel?: string;
export function DetailTabs({ detail, modes, remote, children, onSelectVersion, versionActionLabel, cacheIdentity, onContextStale }: {
detail: SkillDetail; modes: SkillHubConfig['detailTabs']; remote: Remote; children: ReactNode; onSelectVersion?(version: string): void; versionActionLabel?: string; cacheIdentity?: string; onContextStale?(): void;
}) {
const [selected, setSelected] = useState<Tab>('details');
const identity = cacheIdentity ?? JSON.stringify([detail.namespace, detail.slug, detail.version]);
const client = useQueryClient();
const uiKey = ['skillhub-detail-ui', identity];
const saved = client.getQueryData<{ selected: Tab; pages: { versions: number; reviews: number } }>(uiKey);
const [pages, setPages] = useState(saved?.pages ?? { versions: 0, reviews: 0 });
const [selected, setSelected] = useState<Tab>(saved?.selected ?? 'details');
useEffect(() => { client.setQueryData(['skillhub-detail-ui', identity], { selected, pages }); }, [client, identity, selected, pages]);
const visible = (Object.keys(names) as Tab[]).filter(tab => tab === 'details'
|| modes[tab] === 'show' || (modes[tab] === 'auto' && detail.tabs[tab] === 'available'));
const active = visible.includes(selected) ? selected : 'details';
@ -23,18 +30,18 @@ export function DetailTabs({ detail, modes, remote, children, onSelectVersion, v
{visible.map(tab => <TabsContent key={tab} value={tab}>
{active !== tab ? null : tab === 'details' ? <><div className="astron-sh-reading-body">{children}</div><footer className="astron-sh-panel-footer"><span>SKILL.md</span><span>当前版本 {detail.version}</span></footer></> : tab === 'securityAudit' ? <><div className="astron-sh-reading-body astron-sh-audit-empty"><Icon name="info" /><h3>当前来源未提供公开审核信息</h3><p>当前来源的审核信息不支持匿名读取。</p><p>请检查技能内容与来源,不能据此判断技能已通过安全审核。</p></div><footer className="astron-sh-panel-footer">审核状态取决于当前来源提供的信息</footer></>
: <>
<ModulePage key={`${detail.detailToken}/${tab}`} tab={tab} token={detail.detailToken} remote={remote} detail={detail} onSelectVersion={onSelectVersion} versionActionLabel={versionActionLabel} /></>}
<ModulePage key={`${identity}/${tab}`} cacheIdentity={identity} page={pages[tab]} setPage={page => setPages(previous => ({ ...previous, [tab]: page }))} onContextStale={onContextStale} tab={tab} token={detail.detailToken} remote={remote} detail={detail} onSelectVersion={onSelectVersion} versionActionLabel={versionActionLabel} /></>}
</TabsContent>)}
</Tabs></Card>;
}
function ModulePage({ tab, token, remote, detail, onSelectVersion, versionActionLabel }: { tab: 'versions' | 'reviews'; token: string; remote: Remote; detail: SkillDetail; onSelectVersion?(version: string): void; versionActionLabel?: string }) {
const [page, setPage] = useState(0);
const result = useQuery({ queryKey: ['skillhub-module', token, tab, page], retry: false,
function ModulePage({ tab, token, remote, detail, onSelectVersion, versionActionLabel, cacheIdentity, onContextStale, page, setPage }: { page: number; setPage(page: number): void; tab: 'versions' | 'reviews'; token: string; remote: Remote; detail: SkillDetail; onSelectVersion?(version: string): void; versionActionLabel?: string; cacheIdentity?: string; onContextStale?(): void }) {
const result = useQuery({ ...readCacheOptions, queryKey: ['skillhub-module', cacheIdentity, tab, page], retry: false,
queryFn: async ({ signal }) => {
const response = tab === 'versions' ? await remote.listVersions(token, page, signal) : await remote.listReviews(token, page, signal);
if (!response.ok) {
const error = response.error;
if (error.code === 'skillhub/context-stale') throw new StaleModuleError('详情已过期,正在重新读取。');
if (error.code === 'skillhub/registry-error') {
if (error.details.reason === 'ANONYMOUS_DENIED') throw new Error('当前来源不允许匿名读取此项信息。');
if (error.details.reason === 'NOT_FOUND') throw new Error('未找到此项信息,无法判断是内容不存在还是来源尚未支持。');
@ -44,6 +51,12 @@ function ModulePage({ tab, token, remote, detail, onSelectVersion, versionAction
return response.value;
},
});
const staleToken = useRef<string | null>(null);
useEffect(() => {
if (!(result.error instanceof StaleModuleError)) return;
if (staleToken.current === null) { staleToken.current = token; onContextStale?.(); }
else if (staleToken.current !== token) { staleToken.current = null; void result.refetch(); }
}, [result.error, token, onContextStale, result.refetch]);
if (result.isPending) return <div className="astron-sh-reading-body" role="status">正在读取{names[tab]}…</div>;
if (result.isError) return <div className="astron-sh-reading-body" role="alert"><p>{result.error.message}</p><Button onClick={() => { void result.refetch(); }}>重试</Button></div>;
const data = result.data;
@ -70,6 +83,7 @@ function ModulePage({ tab, token, remote, detail, onSelectVersion, versionAction
</nav>
</>;
}
class StaleModuleError extends Error {}
function Rating({ score }: { score: number }) {
return <span className="astron-sh-stars" aria-label={`评分 ${score} / 5`} title={`${score} / 5`}>
{[1, 2, 3, 4, 5].map(value => <span key={value} data-filled={value <= Math.round(score)}><Icon name="star" /></span>)}

View file

@ -18,10 +18,12 @@ import { SearchToolView } from './search-tool-view.js';
import { CatalogUnavailableError, InstallToolView } from './install-tool-view.js';
import type { InstallResult } from './install-result.js';
import { QueryClient } from '@tanstack/react-query';
import { installedNavigationFor } from './installed-navigation.js';
import { CatalogPage, createCatalogNavigation } from './catalog-page.js';
import { UpgradeRequests, RemovalRequests } from './upgrade-requests.js';
import { InstallationRequests } from './installation-requests.js';
import type { InstalledServices } from './installed-skills.js';
import type { UpgradeServices } from './upgrade-control.js';
import type { InstallationServices } from './install-control.js';
import { TYPERT_REMOTE } from '../../lib/typert.remote-client.js';
@ -59,7 +61,24 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
if (!scope) throw new Error('当前会话已关闭,请重新打开会话。');
return withSessionRemote(scope, action);
};
return { sessionId, requests, compatibility: () => ctx.remote.skillhub.getCompatibility(),
const upgrades: UpgradeServices = { sessionId, upgradeRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(),
prepareUpgrade: (revision, itemKey, signal) => call(remote => remote.skillhub.prepareUpgrade(revision, itemKey, signal)),
prepareVersionUpgrade: (revision, itemKey, version, signal) => call(remote => remote.skillhub.prepareVersionUpgrade(revision, itemKey, version, signal)),
executePlan: (input, signal) => call(remote => remote.skillhub.executePlan(input, signal)),
operation: (id, host) => call(remote => remote.skillhub.getOperation(id, host)),
recoverOperation: input => call(remote => remote.skillhub.recoverOperation(input)),
};
return { sessionId, requests, upgrades, registryIdentity: async revision => { const source = await ctx.remote.skillhub.getContext(); if (!source.ok || !source.value.configured || source.value.sourceRevision !== revision) throw new Error('来源已变化,请重新读取详情。'); return source.value.registryUrl; },
checkVersionUpdate: (revision, itemKey, version, signal) => call(remote => remote.skillhub.checkVersionUpdate(revision, itemKey, version, signal)),
manageInstalled: (sourceRevision, item) => {
const layout = ctx.get('layout');
if (!layout || !ctx.sessions.scope(SessionId(sessionId))) throw new Error('当前会话不可用。');
const installed = installedNavigationFor(navigation, sessionId, sourceRevision);
Object.assign(installed, { scope: item.scope, keyword: '', selected: item, intent: 'remove', completed: null, removalCompleted: null, triggerKey: item.itemKey });
navigation.selected = null; navigation.tab = 'installed'; navigation.scrollTop = 0;
navigation.installedOrigin = { sessionId, sourceRevision };
layout.selectPanel(panelId);
}, compatibility: () => ctx.remote.skillhub.getCompatibility(),
targets: (token, signal) => call(remote => remote.skillhub.listInstallTargets(token, signal)),
installed: (revision, signal) => call(remote => remote.skillhub.listInstalled(revision, signal)),
start: (input, signal) => call(remote => remote.skillhub.startInstall(input, signal)),
@ -84,7 +103,24 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
name: 'tool.call.toolview', key: 'astron_skillhub_install',
inject: sessionId => {
const receipt = receiptReadsFor(sessionId);
return { services: { client, installation: installationFor(sessionId), receipt, openInstalled: async (result: InstallResult) => {
return { services: { client, installation: installationFor(sessionId), receipt,
confirmation: { client, remote: ctx.remote.skillhub, installation: installationFor(sessionId),
openCatalog: async () => { throw new Error('请从技能中心重新搜索。'); },
openDetail: async (reference: string, signal: AbortSignal) => {
receipt.assertCurrent();
const scope = ctx.sessions.scope(SessionId(sessionId));
if (!scope) throw new Error('当前会话已关闭,请重新搜索技能。');
return withSessionRemote(scope, remote => remote.skillhub.openConversationDetail(reference, signal));
},
// Pending install history is not trusted search history and must never be reissued.
reopenDetail: async () => { throw new Error('此安装选择已过期,请在当前会话重新搜索技能。'); },
acknowledgeDetail: async (reference: string, token: string) => {
receipt.assertCurrent();
const scope = ctx.sessions.scope(SessionId(sessionId));
if (!scope) throw new Error('当前会话已关闭,请重新搜索技能。');
return withSessionRemote(scope, remote => remote.skillhub.acknowledgeConversationDetail(reference, token));
},
}, openInstalled: async (result: InstallResult) => {
const layout = ctx.get('layout');
if (!layout) throw new CatalogUnavailableError();
const reads = receipt;
@ -158,6 +194,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
if (!scope) throw new Error('当前会话已关闭,请重新打开会话。');
return withSessionRemote(scope, remote => remote.skillhub.prepareUninstall(revision, itemKey, signal));
},
prepareVersionUpgrade: installationFor(sessionId).upgrades!.prepareVersionUpgrade,
prepareUpgrade: async (revision, itemKey, signal) => {
const scope = ctx.sessions.scope(SessionId(sessionId));
if (!scope) throw new Error('当前会话已关闭,请重新打开会话。');

View file

@ -1,18 +1,21 @@
import { UpgradeControl, type UpgradeServices } from './upgrade-control.js';
import { InstallationProgressSummary, type InstallationProgressJob } from './installation-progress.js';
import { isInventoryScopeVerified } from './installed-inventory.js';
import { canCollapseHistory } from './operation-presentation.js';
import { OperationDetails } from './operation-details.js';
import { DiscoveryStatus } from './discovery-status.js';
import { HostWriteStatus, useWritableHost, type CompatibilityServices } from './host-compatibility.js';
import { useRef, useState, useSyncExternalStore } from 'react';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { SelectField, Button, Dialog, DialogContent, DialogDescription, DialogTitle } from './ui.js';
import { useInstallationOperation } from './use-installation-operation.js';
import { useQuery } from '@tanstack/react-query';
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
import type { HostInstallRequest, InstallationTargetList, InstalledSkillList, NativeOperation, SkillDetail } from '../host/types.js';
import type { HostInstallRequest, InstallationTargetList, InstalledSkillList, NativeOperation, SkillDetail, InstalledUpdateCheck } from '../host/types.js';
import type { InstallationRequests } from './installation-requests.js';
export interface InstallationServices extends CompatibilityServices {
sessionId: string;
upgrades?: UpgradeServices;
registryIdentity?(revision: string): Promise<string>;
checkVersionUpdate?(revision: string, itemKey: string, version: string, signal: AbortSignal): Promise<RemoteResult<InstalledUpdateCheck>>;
manageInstalled?(revision: string, item: InstalledSkillList['items'][number]): void;
requests: InstallationRequests;
targets(token: string, signal: AbortSignal): Promise<RemoteResult<InstallationTargetList>>;
installed(revision: string, signal: AbortSignal): Promise<RemoteResult<InstalledSkillList>>;
@ -25,81 +28,116 @@ function read<T>(response: RemoteResult<T>): T {
throw new Error('暂时无法确认安装状态,请检查连接或重新打开当前会话。');
}
export function InstallControl({ detail, sourceRevision, services, initiatedFrom }: {
detail: SkillDetail; sourceRevision: string; services: InstallationServices; initiatedFrom: HostInstallRequest['initiatedFrom'];
export function InstallControl({ detail, sourceRevision, services, initiatedFrom, onContextStale, presentation = 'panel', onClose, onSubmitted, onManage }: {
detail: SkillDetail; sourceRevision: string; services: InstallationServices; initiatedFrom: HostInstallRequest['initiatedFrom']; onContextStale?(): void; presentation?: 'panel' | 'dialog'; onClose?(): void; onSubmitted?(job: InstallationProgressJob): void; onManage?(item: InstalledSkillList['items'][number]): void;
}) {
const panel = useRef<HTMLElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [confirmation, setConfirmation] = useState<{ key: string; targetToken: string; reinstall: boolean } | null>(null);
const writableHost = useWritableHost(services);
const [scope, setScope] = useState<'project' | 'user'>('user');
const [scope, setScope] = useState<'project' | 'user'>('project');
const [upgradeCandidate, setUpgradeCandidate] = useState<{ item: InstalledSkillList['items'][number]; version: string; source: string } | null>(null);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const refreshedToken = useRef<string | null>(null);
const scopeChosen = useRef(false);
const targets = useQuery({ queryKey: ['skillhub-install-targets', services.sessionId, detail.detailToken], retry: false,
queryFn: async ({ signal }) => read(await services.targets(detail.detailToken, signal)) });
queryFn: async ({ signal }) => {
const response = await services.targets(detail.detailToken, signal);
if (!response.ok && response.error.code === 'skillhub/context-stale') throw new StaleTargetError('详情已过期,请重新读取。');
return read(response);
} });
useEffect(() => {
if (targets.error instanceof StaleTargetError && refreshedToken.current !== detail.detailToken) {
refreshedToken.current = detail.detailToken;
onContextStale?.();
}
}, [targets.error, detail.detailToken, onContextStale]);
const host = targets.data?.hostInstanceId ?? '';
const key = JSON.stringify([services.sessionId, host, sourceRevision, detail.namespace, detail.slug, detail.version, scope]);
const attempt = useSyncExternalStore(services.requests.subscribe, () => services.requests.get(key), () => undefined);
const operationId = attempt?.operationId ?? attempt?.operation?.operationId;
const operation = useInstallationOperation(services, host, operationId);
const result = operation.data ?? attempt?.operation;
const foldable = canCollapseHistory(operation.data, operation.isError);
const target = targets.data?.targets.find(item => item.scope === scope);
const local = useQuery({ queryKey: ['skillhub-installed', services.sessionId, sourceRevision], retry: false,
enabled: true,
queryFn: async ({ signal }) => read(await services.installed(sourceRevision, signal)) });
const current = local.data?.items.find(item => item.namespace === detail.namespace && item.slug === detail.slug && item.scope === scope);
const inventoryVerified = !local.isError && !local.isFetching && isInventoryScopeVerified(local.data, scope);
const upgradeSource = useQuery({ queryKey: ['skillhub-upgrade-source', services.sessionId, sourceRevision], retry: false,
enabled: !!services.upgrades && !!services.registryIdentity && current?.state === 'present' && current.version !== detail.version,
queryFn: () => services.registryIdentity!(sourceRevision) });
const versionUpdate = useQuery({ queryKey: ['skillhub-update-check', services.sessionId, sourceRevision, current?.itemKey, detail.version], retry: false,
enabled: !!services.upgrades && !!services.checkVersionUpdate && inventoryVerified && current?.state === 'present' && current.version !== detail.version,
queryFn: async ({ signal }) => read(await services.checkVersionUpdate!(sourceRevision, current!.itemKey, detail.version, signal)) });
const absent = inventoryVerified && !current;
const records = local.data?.items.filter(item => item.namespace === detail.namespace && item.slug === detail.slug) ?? [];
const result = operation.data ?? attempt?.operation;
const completed = result?.phase === 'completed';
const progressVisible = !!attempt && !completed;
useEffect(() => {
if (scopeChosen.current || !targets.data || !local.data || local.isError || local.isFetching || targets.isFetching) return;
scopeChosen.current = true;
const initial = preferredInstallationScope(local.data, detail, targets.data.targets.map(item => item.scope));
if (initial) setScope(initial);
}, [targets.data, targets.isFetching, local.data, local.isError, local.isFetching, detail]);
useEffect(() => {
if (attempt && onSubmitted && (attempt.state === 'submitting' || attempt.state === 'unresolved'
|| (result && !['completed', 'failed', 'unknown', 'recovery-required'].includes(result.phase)))) {
onSubmitted({ key, sessionId: services.sessionId, sourceRevision, hostInstanceId: host,
namespace: detail.namespace, slug: detail.slug, version: detail.version, title: detail.title, scope });
}
}, [attempt, result, onSubmitted, key, services.sessionId, sourceRevision, host, detail, scope]);
function manage() {
if (!current || !inventoryVerified) return;
try { if (onManage) onManage(current); else services.manageInstalled?.(sourceRevision, current); }
catch { setSubmissionError('无法打开管理页面,请前往已安装重试。'); }
}
function submit(reinstall = false) {
if (!target || !host || (!attempt && !writableHost.canWrite)) return;
if (reinstall && (!absent || !writableHost.canWrite || operation.data?.phase !== 'completed' || !operationId)) return;
setSubmissionError(null);
void services.requests.submit(key, { detailToken: detail.detailToken, targetToken: target.targetToken,
const pending = services.requests.submit(key, { detailToken: detail.detailToken, targetToken: target.targetToken,
hostInstanceId: host, initiatedFrom }, async input => read(await services.start(input, new AbortController().signal)), reinstall ? operationId : undefined)
.catch(error => setSubmissionError(error instanceof Error ? error.message : '无法保存安装请求。'));
if (services.requests.get(key)) onSubmitted?.({ key, sessionId: services.sessionId, sourceRevision, hostInstanceId: host, namespace: detail.namespace, slug: detail.slug, version: detail.version, title: detail.title, scope });
void pending;
}
return <section ref={panel} aria-label="安装技能" className="astron-sh-install-panel">
<h3>安装 {detail.version}</h3>
return <section ref={panel} aria-label="安装技能" className={`astron-sh-install-panel${presentation === 'dialog' ? ' astron-sh-install-dialog-control' : ''}`}>
{presentation === 'panel' && <h3>安装管理</h3>}
{upgradeCandidate && services.upgrades && <UpgradeControl key={`${upgradeCandidate.item.itemKey}:${upgradeCandidate.version}`} presentation="dialog" eligible
services={services.upgrades} sourceRevision={sourceRevision} source={upgradeCandidate.source} item={upgradeCandidate.item}
targetVersion={upgradeCandidate.version} onClosed={() => setUpgradeCandidate(null)} />}
<HostWriteStatus state={writableHost} />
<label className="astron-sh-scope-field">安装范围 <SelectField label="安装范围" value={scope} disabled={attempt?.state === 'submitting'} onValueChange={value => setScope(value as 'project' | 'user')}
options={[{ value: 'project', label: '当前项目' }, { value: 'user', label: '当前用户' }]} /></label>
{targets.isPending ? <p role="status">正在确认安装位置…</p> : targets.isError
? <p role="alert">无法确认安装位置。<button onClick={() => { void targets.refetch(); }}>重试</button></p>
: !target ? <p>当前范围不可安装,请选择其他范围或检查 DSH 会话设置。</p> : null}
{local.isPending || local.isFetching ? <p role="status">正在核验当前安装记录…</p>
: !inventoryVerified ? <p role="alert">当前安装记录无法完整核验,请重试或前往已安装查看。</p>
: current ? current.state !== 'present' ? <p role="alert">当前范围记录版本:{current.version}。本地状态异常,请前往已安装核验。</p>
: <div className="astron-sh-current-install"><strong>{current.version === detail.version ? '已安装此版本' : `已安装 v${current.version}`}</strong><p>{current.version === detail.version ? '当前范围文件存在。' : '此范围已有其他版本。请在已安装中检查升级,不能直接覆盖或降级。'}</p></div>
: <p className="astron-sh-install-state">当前范围未安装</p>}
{!attempt && <Button ref={trigger} variant="default" disabled={!writableHost.canWrite || !target || targets.isFetching || !inventoryVerified || !!current} onClick={() => target && setConfirmation({ key, targetToken: target.targetToken, reinstall: false })}>{current?.state === 'present' && current.version === detail.version ? '已安装此版本' : `安装到${scope === 'project' ? '当前项目' : '当前用户'}`}</Button>}
{(!inventoryVerified) && !local.isPending && <Button disabled={local.isFetching} onClick={() => { void local.refetch(); }}>核验当前安装状态</Button>}
{submissionError && <p role="alert">{submissionError}</p>}
{attempt?.state === 'submitting' && <p role="status">正在提交安装请求…</p>}
{attempt?.state === 'unresolved' && <div role="alert"><p>尚未确认请求结果。查询原请求不会重复安装。</p><button onClick={() => submit()} disabled={!target}>查询原请求</button></div>}
{attempt?.state === 'accepted' && <div aria-live="polite">
{operation.isError || operation.data === null ? <p role="alert">安装状态暂时无法确认。<button onClick={() => { void operation.refetch(); }}>重新查询</button></p>
: result?.phase === 'completed' ? <>
{(result.warningCount > 0 || !!result.resultDetails?.warnings.length) && <p role="alert">此操作存在需核验告警,请展开操作详情查看。</p>}
{result.discovery !== 'confirmed' && <p role="status">尚未确认 DSH 发现此操作对应的技能内容,请在操作详情中重新核验。</p>}
</> : result?.phase === 'failed' ? <p role="alert">安装未完成。请查看操作详情并确认恢复状态。</p>
: result && ['recovery-required', 'unknown'].includes(result.phase) ? <p role="alert">操作需要恢复核验,请勿重复安装。</p>
: <p role="status">正在安装,请稍候…</p>}
{operation.data?.phase === 'completed' && absent && <Button ref={trigger} disabled={!writableHost.canWrite || !target || targets.isFetching} onClick={() => target && setConfirmation({ key, targetToken: target.targetToken, reinstall: true })}>重新安装到{scope === 'project' ? '当前项目' : '当前用户'}</Button>}
<details className="astron-sh-operation-fold" open={foldable ? undefined : true} onToggle={event => { if (!foldable && !event.currentTarget.open) event.currentTarget.open = true; }}><summary>操作详情</summary>
<p>这是原操作回执,当前安装状态以本页核验的库存为准。</p>
{result?.phase === 'completed' && <p>{result.files === 'already-installed' ? '此版本已安装。' : '技能文件安装完成。'}</p>}
{result && <p>{result.context.sourceLabel} · {result.targetLabel} · {result.version}</p>}
{result?.phase === 'completed' && <DiscoveryStatus confirmed={result.discovery === 'confirmed'} checkedAt={operation.dataUpdatedAt} refreshing={operation.isFetching} refresh={() => { void operation.refetch(); }} />}
{!operation.isError && operation.data && <OperationDetails operation={operation.data} />}
<button disabled={local.isFetching} onClick={() => { void local.refetch(); }}>核验当前安装状态</button>
</details>
<label className="astron-sh-scope-field">安装范围 <SelectField label="安装范围" value={scope} disabled={attempt?.state === 'submitting'} onValueChange={value => { scopeChosen.current = true; setScope(value as 'project' | 'user'); }}
options={[{ value: 'project', label: '仅此项目', disabled: !targets.data?.targets.some(item => item.scope === 'project') && !records.some(item => item.scope === 'project') }, { value: 'user', label: '所有项目', disabled: !targets.data?.targets.some(item => item.scope === 'user') && !records.some(item => item.scope === 'user') }]} /></label>
<p className="astron-sh-scope-help">{scope === 'project' ? '仅当前工作区可用' : '此用户的所有项目可用'}</p>
{targets.isError && <p role="alert">无法读取安装位置。<Button size="sm" onClick={() => { void targets.refetch(); }}>重试</Button></p>}
{local.isPending || (!local.data && local.isFetching) ? <p role="status">正在读取安装状态…</p>
: !isInventoryScopeVerified(local.data, scope) || local.isError ? <p role="alert">安装状态暂不可用。<Button size="sm" onClick={() => { void local.refetch(); }}>重试</Button></p>
: current ? <div className="astron-sh-current-install" data-state={current.state}>
<strong>{current.state === 'present' ? `已安装 v${current.version}` : '安装记录异常'}</strong>
{(onManage || services.manageInstalled) && <Button size="sm" variant="destructive" disabled={!inventoryVerified} onClick={manage}>{current.state === 'present' ? '卸载' : '管理'}</Button>}
</div> : records.length > 0 ? <p className="astron-sh-other-install">已安装:{records.map(item => `${item.scope === 'user' ? '所有项目' : '仅此项目'} v${item.version}`).join('、')}</p> : null}
{upgradeSource.isError && <p role="alert">无法核验当前来源。<Button size="sm" onClick={() => { void upgradeSource.refetch(); }}>重试</Button></p>}
{current?.state === 'present' && current.version !== detail.version && services.checkVersionUpdate && <div className="astron-sh-version-action">
{versionUpdate.isFetching ? <p role="status">正在核对所选版本…</p> : versionUpdate.isError ? <p role="alert">无法核对所选版本。<Button size="sm" onClick={() => { void versionUpdate.refetch(); }}>重试</Button></p>
: versionUpdate.data?.state === 'upgradable' && versionUpdate.data.candidateVersion === detail.version ? <Button variant="default" disabled={!inventoryVerified || !writableHost.canWrite || !upgradeSource.data || upgradeSource.isError || upgradeSource.isFetching} onClick={() => { if (upgradeSource.data) setUpgradeCandidate({ item: current, version: detail.version, source: upgradeSource.data }); }}>升级到 v{detail.version}</Button>
: versionUpdate.data && <p role="status">{selectedVersionUpdateMessage(versionUpdate.data)}</p>}
</div>}
{inventoryVerified && !current && (!attempt || completed) && <Button ref={trigger} variant="default" disabled={!writableHost.canWrite || !target || targets.isFetching}
onClick={() => { if (!target) return; if (presentation === 'dialog') submit(completed); else setConfirmation({ key, targetToken: target.targetToken, reinstall: completed }); }}>
{scope === 'project' ? '安装到此项目' : '安装到所有项目'}</Button>}
{current && completed && (result.warningCount > 0 || !!result.resultDetails?.warnings.length) && <p role="alert">安装有告警,请在已安装中核验。</p>}
{submissionError && <p role="alert">{submissionError}</p>}
{progressVisible && attempt && <InstallationProgressSummary attempt={attempt} operation={operation.data ?? attempt.operation} readFailed={operation.isError || operation.data === null}
onCheck={() => { if (attempt.state === 'unresolved') submit(); else void operation.refetch(); }} />}
{presentation === 'dialog' && <Button onClick={onClose}>{attempt ? '关闭' : '取消'}</Button>}
<Dialog open={confirmation !== null} onOpenChange={open => { if (!open) setConfirmation(null); }}>
<DialogContent container={panel.current} onCloseAutoFocus={event => { event.preventDefault(); trigger.current?.focus(); }}>
<DialogTitle>确认{confirmation?.reinstall ? '重新安装' : '安装'}技能?</DialogTitle>
<DialogDescription>确认技能、版本和安装范围后,才会向当前 DSH Host 提交安装请求。</DialogDescription>
<dl><dt>技能</dt><dd>@{detail.namespace}/{detail.slug}</dd><dt>版本</dt><dd>{detail.version}</dd>
<dt>安装范围</dt><dd>{scope === 'project' ? '当前项目' : '当前用户'}</dd></dl>
<dt>安装范围</dt><dd>{scope === 'project' ? '仅此项目' : '所有项目'}</dd></dl>
{(confirmation?.key !== key || confirmation?.targetToken !== target?.targetToken) && <p role="alert">会话、来源或安装目标已变化,请取消后重新确认。</p>}
<div className="astron-sh-dialog-actions"><Button onClick={() => setConfirmation(null)}>取消</Button>
<Button variant="default" disabled={(confirmation?.key !== key || confirmation?.targetToken !== target?.targetToken) || !writableHost.canWrite || !target || targets.isFetching || attempt?.state === 'submitting' || !inventoryVerified || !!current}
@ -108,3 +146,19 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom
</Dialog>
</section>;
}
class StaleTargetError extends Error {}
/** Prefer the scope already installed, rather than presenting a misleading second install. */
export function preferredInstallationScope(inventory: InstalledSkillList, detail: Pick<SkillDetail, 'namespace' | 'slug'>, available: readonly ('user' | 'project')[]): 'user' | 'project' | undefined {
const records = inventory.items.filter(item => item.namespace === detail.namespace && item.slug === detail.slug);
return (['project', 'user'] as const).find(scope => records.some(item => item.scope === scope))
?? (['project', 'user'] as const).find(scope => available.includes(scope));
}
export function selectedVersionUpdateMessage(update: InstalledUpdateCheck): string {
if (update.state === 'current') return '已是此版本,无需升级。';
const messages: Record<string, string> = { 'remote-older': '所选版本较旧,暂不支持降级。', 'unknown-order': '无法可靠比较版本,暂不能升级。',
'same-version-content-changed': '此版本内容已变化,请核实来源。', 'local-record-unverified': '请先核验当前安装。' };
return messages[update.reason] ?? '所选版本暂不可升级,请重新读取版本信息。';
}

View file

@ -0,0 +1,43 @@
import { useRef, useState } from 'react';
import { QueryClientProvider, useQuery, type QueryClient } from '@tanstack/react-query';
import type { SkillDetail, HostInstallRequest, InstalledSkillList } from '../host/types.js';
import { InstallationProgressPanel, type InstallationProgressJob } from './installation-progress.js';
import { InstallControl, type InstallationServices } from './install-control.js';
import { Dialog, DialogContent, DialogDescription, DialogTitle, Button } from './ui.js';
import { NativeControlStyles } from './native-control-styles.js';
import { SkillAvatar } from './skill-card.js';
/** A fresh, identity-bound read for confirmation; no navigation or write on opening. */
export function SkillInstallDialog({ client, identity, load, services, initiatedFrom, close, onManage }: {
client: QueryClient; identity: string; load(signal: AbortSignal): Promise<{ detail: SkillDetail; sourceRevision: string }>;
services: InstallationServices; initiatedFrom: HostInstallRequest['initiatedFrom']; close(): void; onManage?(item: InstalledSkillList['items'][number]): void;
}) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [expanded, setExpanded] = useState(false);
const opener = useRef(typeof document === 'undefined' ? null : document.activeElement);
return <div ref={setContainer} className="skillhub-controls"><NativeControlStyles />{container && <Dialog open onOpenChange={open => { if (!open) close(); }}>
<DialogContent className={`astron-sh-dialog${expanded ? ' astron-sh-dialog-expanded' : ''}`} container={container} onCloseAutoFocus={event => { event.preventDefault(); if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus(); }}>
<DialogTitle>安装技能</DialogTitle><DialogDescription>选择技能的可用范围</DialogDescription>
<QueryClientProvider client={client}><Content identity={identity} load={load} services={services} initiatedFrom={initiatedFrom} close={close} onManage={onManage} expand={() => setExpanded(true)} /></QueryClientProvider>
</DialogContent>
</Dialog>}</div>;
}
function Content({ identity, load, services, initiatedFrom, close, expand, onManage }: {
identity: string; expand(): void; load(signal: AbortSignal): Promise<{ detail: SkillDetail; sourceRevision: string }>;
services: InstallationServices; initiatedFrom: HostInstallRequest['initiatedFrom']; close(): void; onManage?(item: InstalledSkillList['items'][number]): void;
}) {
const [job, setJob] = useState<InstallationProgressJob | null>(null);
const result = useQuery({ queryKey: ['skillhub-install-confirmation', services.sessionId, identity], gcTime: 0, retry: false,
refetchOnWindowFocus: false, refetchOnReconnect: false, queryFn: ({ signal }) => load(signal) });
if (result.isPending) return <><p role="status">正在核验技能版本与安装位置…</p><Button onClick={close}>取消</Button></>;
if (result.isError) return <><p role="alert">无法读取安装信息,请重新选择技能或重试。</p><div className="astron-sh-dialog-actions"><Button onClick={close}>取消</Button><Button onClick={() => { void result.refetch(); }}>重试</Button></div></>;
return <div className="astron-sh-install-dialog-layout"><div>
<div className="astron-sh-install-dialog-identity"><SkillAvatar name={result.data.detail.title} namespace={result.data.detail.namespace} slug={result.data.detail.slug} />
<div><strong>{result.data.detail.title}</strong><p>@{result.data.detail.namespace}/{result.data.detail.slug} · {result.data.detail.version}</p></div></div>
{job ? <><dl className="astron-sh-install-confirmed-scope"><dt>安装范围</dt><dd>{job.scope === 'project' ? '仅此项目' : '所有项目'}</dd></dl>
<p className="astron-sh-install-close-note">关闭窗口不会取消已提交的安装。</p><Button autoFocus onClick={close}>关闭</Button></>
: <InstallControl key={result.data.sourceRevision} detail={result.data.detail} sourceRevision={result.data.sourceRevision} services={services} initiatedFrom={initiatedFrom}
presentation="dialog" onClose={close} onManage={onManage} onSubmitted={value => { setJob(value); expand(); }} onContextStale={() => { void result.refetch(); }} />}
</div>{job && <aside aria-label="安装进度"><InstallationProgressPanel job={job} services={services} /></aside>}
</div>;
}

View file

@ -28,3 +28,19 @@ export function parseInstallResult(value: unknown): InstallResult | undefined {
const result = schema.safeParse(value);
return result.success ? result.data : undefined;
}
const pendingSchema = schema.pick({ schemaVersion: true, kind: true, toolCallId: true,
namespace: true, slug: true, version: true, sourceLabel: true, sourceRevision: true, createdAt: true }).extend({
status: z.literal('awaiting-confirmation'), selectionRef: z.uuid(),
}).strict();
export type PendingInstallResult = z.infer<typeof pendingSchema>;
/** A pending history snapshot can only open a still-valid Host choice, never restore authorization. */
export function parsePendingInstallResult(value: unknown): PendingInstallResult | undefined {
const result = pendingSchema.safeParse(value);
return result.success ? result.data : undefined;
}
export function isCancelledInstallResult(value: unknown): boolean {
return z.object({ schemaVersion: z.literal(1), kind: z.literal('skillhub-install'),
status: z.literal('cancelled'), toolCallId: text }).strict().safeParse(value).success;
}

View file

@ -11,14 +11,17 @@ import { useState } from 'react';
import { QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { QueryClient } from '@tanstack/react-query';
import type { InstallationServices } from './install-control.js';
import { parseInstallResult, parseInstallFailure } from './install-result.js';
import { parseInstallResult, parseInstallFailure, parsePendingInstallResult, isCancelledInstallResult } from './install-result.js';
import type { InstallFailure } from './install-result.js';
import type { InstallResult } from './install-result.js';
import type { InstallResult, PendingInstallResult } from './install-result.js';
import { SkillInstallDialog } from './install-dialog.js';
import type { SearchViewServices } from './conversation-detail.js';
import { useInstallationOperation } from './use-installation-operation.js';
export interface InstallViewServices {
client: QueryClient;
receipt: ReceiptReads;
confirmation?: SearchViewServices;
installation: Pick<InstallationServices, 'sessionId' | 'operation' | 'installed'>;
openInstalled(result: InstallResult): Promise<void>;
}
@ -51,11 +54,14 @@ function InstallToolViewContent(props: ToolCallViewProps & { services: InstallVi
<p role="alert">本次安装请求未确认。请检查原操作或重新查看技能详情,不要直接重复安装。</p>
{props.inspect && <button onClick={props.inspect}>查看调用详情</button>}
</section>;
if (isCancelledInstallResult(props.block.meta)) return <p role="status">安装未提交,未写入技能文件。</p>;
const failure = parseInstallFailure(props.block.meta);
if (failure) return <section aria-label="SkillHub 安装请求未确认">
<p role="alert">{failureMessages[failure.code]}</p>
{props.inspect && <button onClick={props.inspect}>查看调用详情</button>}
</section>;
const pending = parsePendingInstallResult(props.block.meta);
if (pending) return <PendingInstallation result={pending} services={props.services.confirmation} />;
const result = parseInstallResult(props.block.meta);
if (!result) return <section aria-label="SkillHub 安装记录">
<p>这条记录的格式无法识别,不能核验安装结果。</p>
@ -131,3 +137,23 @@ function InstallationResult({ result, services, receipt, openInstalled }: {
{actionStatus && <p role="status">{actionStatus}</p>}
</section>;
}
export function PendingInstallation({ result, services }: { result: PendingInstallResult; services?: SearchViewServices }) {
const [open, setOpen] = useState(false);
return <section className="astron-sh-conversation-result astron-sh-conversation-install" aria-label="选择安装范围">
<header className="astron-sh-conversation-identity"><SkillAvatar slug={result.slug} namespace={result.namespace} />
<div><h4>{result.slug}</h4><p>@{result.namespace}/{result.slug} · v{result.version}</p></div>
<span className="astron-sh-install-badge" data-tone="warning">等待确认</span></header>
<p>尚未安装。请选择安装范围并确认。</p>
<p className="astron-sh-conversation-caption">当前项目:供这个项目中的 DSH 会话使用。<br />当前用户:可在其他项目中发现,读取仍受会话权限限制。</p>
<Button disabled={!services} onClick={() => setOpen(true)}>选择范围并安装</Button>
{!services && <p role="alert">当前会话无法打开确认界面,请重新搜索并选择技能。</p>}
<p className="astron-sh-conversation-caption">这是等待确认时的记录;关闭或取消不会安装。来源或会话变化后请重新搜索。</p>
{open && services && <SkillInstallDialog client={services.client} services={services.installation} initiatedFrom="conversation"
identity={JSON.stringify([result.toolCallId, result.selectionRef])} close={() => setOpen(false)} load={async signal => {
const [context, detail] = await Promise.all([services.remote.getContext(), services.openDetail(result.selectionRef, signal)]);
if (!context.ok || !detail.ok || context.value.sourceRevision !== result.sourceRevision) throw new Error('原选择已失效,请重新搜索。');
return { detail: detail.value, sourceRevision: context.value.sourceRevision };
}} />}
</section>;
}

View file

@ -0,0 +1,63 @@
import { useSyncExternalStore } from 'react';
import type { NativeOperation } from '../host/types.js';
import type { InstallationAttempt } from './installation-requests.js';
import type { InstallationServices } from './install-control.js';
import { useInstallationOperation } from './use-installation-operation.js';
import { Button } from './ui.js';
import { SkillAvatar } from './skill-card.js';
export interface InstallationProgressJob {
key: string; sessionId: string; sourceRevision: string; hostInstanceId: string;
namespace: string; slug: string; version: string; title: string; scope: 'user' | 'project';
}
export function installationProgressState(attempt: InstallationAttempt | undefined, operation: NativeOperation | null | undefined, readFailed: boolean) {
if (attempt?.state === 'submitting') return { label: '正在提交安装', message: '请稍候…', state: 'running' as const };
if (!attempt || attempt.state === 'unresolved' || readFailed || operation === null) {
return { label: '安装状态待核验', message: '尚不能确认结果,请核验原请求,不要重复安装。', state: 'stopped' as const };
}
if (!operation) return { label: '正在提交安装', message: '请稍候…', state: 'running' as const };
if (operation.phase === 'completed') {
const warning = operation.warningCount > 0 || !!operation.resultDetails?.warnings.length;
return { label: warning ? '安装完成,需核验告警' : '安装完成',
message: warning ? '请在已安装中核验当前状态。' : operation.discovery === 'confirmed' ? '技能已就绪。' : '文件已安装,DSH 发现尚待确认。', state: 'complete' as const };
}
if (operation.phase === 'failed') return { label: '安装未完成', message: '请核验当前状态后再处理。', state: 'stopped' as const };
if (operation.phase === 'recovery-required' || operation.phase === 'unknown') return { label: '安装结果待核验', message: '操作需要恢复核验,请在已安装中处理。', state: 'stopped' as const };
const labels = { accepted: '准备安装', staging: '正在下载并校验', revalidating: '正在核验安装位置', committing: '正在写入技能', recovering: '正在恢复核验' };
return { label: labels[operation.phase], message: '正在处理,请稍候。', state: 'running' as const };
}
/** Indeterminate until a verified completion; phase names are not percentages. */
export function InstallationProgressSummary({ attempt, operation, readFailed, onCheck }: {
attempt: InstallationAttempt | undefined; operation: NativeOperation | null | undefined; readFailed: boolean; onCheck?(): void;
}) {
const view = installationProgressState(attempt, operation, readFailed);
return <div className="astron-sh-install-progress" data-state={view.state}>
<strong role={view.state === 'stopped' ? 'alert' : 'status'}>{view.label}</strong>
<div role="progressbar" aria-label="安装进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={view.state === 'complete' ? 100 : undefined} aria-valuetext={view.label}
className="astron-sh-install-progress-track" data-state={view.state}><span /></div>
<p>{view.message}</p>
{onCheck && view.state !== 'running' && (view.state !== 'complete' || operation?.discovery !== 'confirmed' || !!operation.warningCount)
&& <Button size="sm" variant="ghost" onClick={onCheck}>核验状态</Button>}
</div>;
}
export function InstallationProgressPanel({ job, services }: { job: InstallationProgressJob; services: InstallationServices }) {
const attempt = useSyncExternalStore(services.requests.subscribe, () => services.requests.get(job.key), () => undefined);
const operation = useInstallationOperation(services, job.hostInstanceId, attempt?.operationId ?? attempt?.operation?.operationId);
async function check() {
if (!attempt) return;
if (attempt.state === 'unresolved') await services.requests.submit(job.key, attempt.request, async input => {
const response = await services.start(input, new AbortController().signal);
if (!response.ok) throw new Error('暂不能确认原请求。');
return response.value;
});
else await operation.refetch();
}
return <section className="astron-sh-install-progress-item" aria-label={`${job.title} 安装进度`}>
<header><SkillAvatar name={job.title} namespace={job.namespace} slug={job.slug} /><div><strong>{job.title}</strong><p>v{job.version} · {job.scope === 'project' ? '仅此项目' : '所有项目'}</p></div>
</header>
<InstallationProgressSummary attempt={attempt} operation={operation.data ?? attempt?.operation} readFailed={operation.isError || operation.data === null} onCheck={() => { void check(); }} />
</section>;
}

View file

@ -2,15 +2,16 @@ import type { ConversationSearchResult } from '../host/search-result.js';
import { readableDate } from './skill-presentation.js';
type Installation = NonNullable<ConversationSearchResult['items'][number]['installation']>;
const scopeLabel = { user: '当前用户', project: '当前项目' };
const scopeLabel = { user: '所有项目', project: '仅此项目' };
const stateLabel = {
present: '文件存在', missing: '安装目录缺失', 'metadata-invalid': '安装记录无法识别',
mismatch: '安装记录与本地文件不一致', unreadable: '本地文件无法读取',
};
export function InstallationStatus({ installation, pending = false, snapshot = false }: {
installation?: Installation; pending?: boolean; snapshot?: boolean;
export function InstallationStatus({ installation, pending = false, snapshot = false, hideEmpty = false }: {
installation?: Installation; pending?: boolean; snapshot?: boolean; hideEmpty?: boolean;
}) {
if (hideEmpty && !pending && installation?.complete && !installation.unavailable.length && !installation.records.length) return null;
const present = installation?.records.filter(record => record.state === 'present') ?? [];
const abnormal = installation?.records.filter(record => record.state !== 'present') ?? [];
const partial = !installation?.complete || !!installation.unavailable.length;

View file

@ -7,6 +7,7 @@ import type { RemoteResult, TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-ty
import type { InstalledSkillList, InstalledSkillDetail, InstalledUpdateCheck, SkillHubConfig } from '../host/types.js';
import { UpgradeControl, UpgradeHistory } from './upgrade-control.js';
import type { UpgradeServices } from './upgrade-control.js';
import { readCacheOptions } from './read-cache.js';
import { DetailContent } from './detail-content.js';
import { RemovalControl, RemovalHistory, type RemovalServices } from './removal-control.js';
import { InstalledStyles } from './installed-styles.js';
@ -36,9 +37,9 @@ const updateReasons: Record<string, string> = {
RATE_LIMITED: '来源暂时限流,请稍后重试。',
};
export function InstalledSkills({ services, sourceRevision, source, remote, modes, scrollContainer, navigation }: {
export function InstalledSkills({ services, sourceRevision, source, remote, modes, scrollContainer, navigation, onRemove, onUpgrade }: {
services: InstalledServices; sourceRevision: string; source: string; remote: TypertRemoteNamespaceMap['skillhub']; modes: SkillHubConfig['detailTabs'];
scrollContainer: RefObject<HTMLElement>; navigation: InstalledNavigation;
scrollContainer: RefObject<HTMLElement>; navigation: InstalledNavigation; onRemove?(item: InstalledSkillList['items'][number]): void; onUpgrade?(item: InstalledSkillList['items'][number]): void;
}) {
const [scope, setScope] = useState(navigation.scope);
const [keyword, setKeyword] = useState(navigation.keyword);
@ -80,7 +81,7 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
? '会话或来源已变化,请重新打开已安装列表。' : '无法读取本地安装记录,请检查 DSH 会话后重试。');
return response.value;
} });
const detail = useQuery({ queryKey: ['skillhub-installed-detail', services.sessionId, sourceRevision, selected?.itemKey],
const detail = useQuery({ ...readCacheOptions, queryKey: ['skillhub-installed-detail', services.sessionId, sourceRevision, selected?.itemKey],
enabled: !!selected, retry: false, queryFn: async ({ signal }) => {
const response = await services.detail(sourceRevision, selected!.itemKey, signal);
if (!response.ok) throw new Error('安装记录已变化或暂时无法读取,请返回列表刷新。');
@ -97,14 +98,14 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
<button onClick={() => { triggerKey.current = refreshedItem?.itemKey ?? selected.itemKey; setSelected(null); }}>← 返回已安装</button>
<div className="astron-sh-detail-heading"><SkillAvatar slug={selected.slug} namespace={selected.namespace} /><h2 ref={heading} tabIndex={-1}>{selected.slug}</h2></div>
<p>@{selected.namespace}/{selected.slug} · {removed ? '原安装版本' : '已安装版本'} {upgradedVersion ?? selected.version}</p>
<p>{selected.scope === 'user' ? '当前用户' : '当前项目'} · {removed ? '此安装记录已移除' : states[currentLocalState ?? selected.state]}</p>
<p>{selected.scope === 'user' ? '所有项目' : '仅此项目'} · {removed ? '此安装记录已移除' : states[currentLocalState ?? selected.state]}</p>
<div className="astron-sh-detail-layout"><article className="astron-sh-detail-main astron-sh-documentation">{removed ? <p role="status">此安装位置的操作已完成,请查看下方结果或返回列表。</p>
: upgradedVersion ? <p role="status">安装版本已更新。<button disabled={!refreshedItem || refreshedItem.version !== upgradedVersion}
onClick={() => { if (refreshedItem) setSelected(refreshedItem); }}>查看升级后详情</button></p>
: detail.isPending ? <p role="status">正在核对本地记录并读取固定版本详情…</p>
: detail.isError ? <p role="alert">{detail.error.message}</p>
: detail.data.content.state === 'local-only' ? <p role="status">远端详情暂时不可用,以上保留本地安装信息。未切换到其他版本。</p>
: <DetailContent detail={detail.data.content.detail} modes={modes} remote={remote} retry={() => { void detail.refetch(); }} />}
: <DetailContent detail={detail.data.content.detail} modes={modes} remote={remote} retry={() => { void detail.refetch(); }} cacheIdentity={JSON.stringify([services.sessionId, sourceRevision, selected.namespace, selected.slug, detail.data.content.detail.version])} onContextStale={() => { void detail.refetch(); }} />}
</article><aside className="astron-sh-detail-aside">
{!removed && !upgradedVersion && <Card className="astron-sh-version-update"><CardHeader><h3>版本与更新</h3></CardHeader><CardContent><div className="astron-sh-update-actions"><Button size="sm" variant="outline" disabled={detail.isFetching} onClick={() => { void detail.refetch(); }}>重新读取详情</Button>
<Button size="sm" variant="outline" disabled={update.isFetching} onClick={() => { void update.refetch(); }}>检查更新</Button></div>
@ -135,20 +136,22 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
<input type="search" aria-label="过滤已安装技能" placeholder="名称、命名空间或已安装版本" value={keyword} onChange={event => setKeyword(event.target.value)} />
</label>
<label>安装范围 <SelectField ref={filter} label="筛选安装范围" value={scope} onValueChange={value => setScope(value as typeof scope)}
options={[{ value: 'all', label: '全部范围' }, { value: 'project', label: '当前项目' }, { value: 'user', label: '当前用户' }]} /></label>
options={[{ value: 'all', label: '全部范围' }, { value: 'project', label: '仅此项目' }, { value: 'user', label: '所有项目' }]} /></label>
<button onClick={() => { void query.refetch(); }} disabled={query.isFetching}>刷新已安装</button>
{keyword && <button onClick={() => setKeyword('')}>清空过滤</button>}
</div>
<p className="skillhub-installed-note">显示当前 SkillHub 来源在当前用户和项目中的安装记录。“文件存在”不代表内容完整性或安全审核已通过。</p>
{query.isPending ? <CardSkeletons message="正在读取本地安装记录…" /> : query.isError ? <p role="alert">{query.error.message}</p> : <>
{(!query.data.complete || query.data.unavailable.length > 0) && <div role="alert"><p>部分安装记录无法读取,以下列表可能不完整。</p>
{query.data.unavailable.length > 0 && <p>无法读取范围:{query.data.unavailable.map(value => value === 'user' ? '当前用户' : '当前项目').join('、')}</p>}
{query.data.unavailable.length > 0 && <p>无法读取范围:{query.data.unavailable.map(value => value === 'user' ? '所有项目' : '仅此项目').join('、')}</p>}
</div>}
<p role="status">当前显示 {items.length} 项安装记录。</p>
{items.length === 0 ? <p className="skillhub-installed-empty">{term ? '没有匹配的本地安装记录,请调整关键词或安装范围。' : (scope === 'all' ? query.data.complete && query.data.unavailable.length === 0 : isInventoryScopeVerified(query.data, scope)) ? '当前范围没有该来源的安装记录。' : '当前范围没有可展示的记录,请先处理读取问题。'}</p>
: <div className="astron-sh-card-grid" aria-label="本地安装记录">{items.map(item => <InstalledRow key={item.itemKey} item={item} services={services} sourceRevision={sourceRevision}
buttonRef={(action, node) => { if (triggerKey.current === item.itemKey && intent.current === action) trigger.current = node; }}
navigate={(action, button) => {
if (action === 'remove' && onRemove) { onRemove(item); return; }
if (action === 'upgrade' && onUpgrade) { onUpgrade(item); return; }
listScroll.current = scrollContainer.current?.scrollTop ?? 0;
intent.current = action; triggerKey.current = item.itemKey; trigger.current = button; setSelected(item);
}} />)}</div>}
@ -168,7 +171,7 @@ function InstalledRow({ item, services, sourceRevision, navigate, buttonRef }: {
return <Card className="astron-sh-installed-card">
<CardHeader className="astron-sh-skill-heading"><SkillAvatar slug={item.slug} namespace={item.namespace} /><div>
<h3>{item.slug}</h3><span className="skillhub-installed-coordinate">@{item.namespace}/{item.slug}</span></div></CardHeader><CardContent>
<div className="astron-sh-installed-meta"><span>已安装版本 {item.version}</span><span>{item.scope === 'user' ? '当前用户' : '当前项目'}</span></div>
<div className="astron-sh-installed-meta"><span>已安装版本 {item.version}</span><span>{item.scope === 'user' ? '所有项目' : '仅此项目'}</span></div>
<p><span className="astron-sh-state-badge" data-state={item.state}>{states[item.state]}</span></p>
<div aria-live="polite">
{update.isFetching ? '正在检查更新…' : update.isError ? update.error.message : !update.data ? '尚未检查'

View file

@ -0,0 +1,14 @@
// Same complete leading fence contract as the web reader. Never parse this block
// as installation identity; Registry metadata is authoritative for that purpose.
const frontmatter = /^(?:---[\t ]*\r?\n[\s\S]*?\r?\n---[\t ]*(?:\r?\n|$)|\+\+\+[\t ]*\r?\n[\s\S]*?\r?\n\+\+\+[\t ]*(?:\r?\n|$))/u;
export function stripMarkdownFrontmatter(content: string): string {
const normalized = content.replace(/^\uFEFF/u, '');
const block = normalized.match(frontmatter)?.[0];
if (!block) return normalized;
// Two ordinary thematic breaks must not swallow a Markdown paragraph.
const yaml = block.startsWith('---');
const hasFields = block.split(/\r?\n/u).slice(1, -1).some(line => yaml
? /^[A-Za-z_][\w.-]*\s*:/u.test(line)
: /^(?:[A-Za-z_][\w.-]*\s*=|\[[^\]]+\])/u.test(line));
return hasFields ? normalized.slice(block.length) : normalized;
}

View file

@ -0,0 +1,20 @@
export function MarkdownStyles() {
return <style>{`
.skillhub-controls .astron-sh-markdown{font-size:14px;line-height:1.85;overflow-wrap:anywhere;color:var(--astron-sh-fg,inherit);max-width:82ch;margin-inline:auto}
.skillhub-controls .astron-sh-markdown>:first-child{margin-top:0}
.skillhub-controls .astron-sh-markdown h1{font-size:24px;line-height:1.4;margin:1.4em 0 .7em}
.skillhub-controls .astron-sh-markdown h2{font-size:20px;line-height:1.5;margin:1.6em 0 .6em;padding-bottom:.45em;border-bottom:1px solid var(--astron-sh-border,#8883)}
.skillhub-controls .astron-sh-markdown h3{font-size:16px;line-height:1.5;margin:1.4em 0 .5em}
.skillhub-controls .astron-sh-markdown p{margin:.85em 0}
.skillhub-controls .astron-sh-markdown ul,.skillhub-controls .astron-sh-markdown ol{padding-left:1.6em;margin:.8em 0}
.skillhub-controls .astron-sh-markdown li{margin:.3em 0}
.skillhub-controls .astron-sh-markdown pre{overflow-x:auto;padding:16px;border:1px solid var(--astron-sh-border,#8883);border-radius:10px;background:var(--astron-sh-soft,#8881);line-height:1.65;font-size:12px}
.skillhub-controls .astron-sh-markdown code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.9em}
.skillhub-controls .astron-sh-markdown :not(pre)>code{padding:2px 5px;border-radius:4px;background:var(--astron-sh-soft,#8881)}
.skillhub-controls .astron-sh-markdown blockquote{margin:1em 0;padding:2px 16px;border-left:3px solid var(--astron-sh-accent,#8886);color:var(--astron-sh-muted,inherit)}
.skillhub-controls .astron-sh-markdown table{display:block;overflow-x:auto;border-collapse:collapse;font-size:13px;margin:1em 0}
.skillhub-controls .astron-sh-markdown th,.skillhub-controls .astron-sh-markdown td{border:1px solid var(--astron-sh-border,#8883);padding:8px 12px;text-align:left}
.skillhub-controls .astron-sh-markdown th{background:var(--astron-sh-soft,#8881)}
.skillhub-controls .astron-sh-markdown hr{border:0;border-top:1px solid var(--astron-sh-border,#8883);margin:1.5em 0}
`}</style>;
}

View file

@ -0,0 +1,33 @@
import { useState } from 'react';
import type { InstalledSkillList } from '../host/types.js';
import type { InstalledServices } from './installed-skills.js';
import { useInstalledUpdate } from './installed-update.js';
import { Button, SelectField } from './ui.js';
type InstalledItem = InstalledSkillList['items'][number];
export function MarketCardActions({ records, services, sourceRevision, verified, onInstall, onManage }: {
records: InstalledItem[]; services?: InstalledServices; sourceRevision: string; verified: boolean;
onInstall(): void; onManage(item: InstalledItem, intent: 'remove' | 'upgrade'): void;
}) {
if (!records.length) return <Button size="sm" variant="default" disabled={!verified} onClick={onInstall}>安装</Button>;
return services && <ManagedActions records={records} services={services} sourceRevision={sourceRevision} verified={verified} onManage={onManage} />;
}
function ManagedActions({ records, services, sourceRevision, verified, onManage }: {
records: InstalledItem[]; services: InstalledServices; sourceRevision: string; verified: boolean;
onManage(item: InstalledItem, intent: 'remove' | 'upgrade'): void;
}) {
const [scope, setScope] = useState('choose');
const item = records.length === 1 ? records[0] : records.find(record => record.scope === scope);
const update = useInstalledUpdate(services, sourceRevision, item?.itemKey);
const removable = verified && !!item && ['present', 'missing'].includes(item.state);
const upgradable = verified && item?.state === 'present' && !update.isFetching && !update.isError && update.data?.state === 'upgradable';
return <>
{records.length > 1 && <SelectField label="选择要管理的安装范围" value={scope} onValueChange={setScope}
options={[{ value: 'choose', label: '选择范围' }, ...records.map(record => ({ value: record.scope, label: record.scope === 'user' ? '所有项目' : '仅此项目' }))]} />}
{upgradable ? <Button size="sm" variant="default" onClick={() => { if (item) onManage(item, 'upgrade'); }}>升级</Button>
: <Button size="sm" variant="ghost" disabled={!verified || item?.state !== 'present' || update.isFetching}
title={update.isError ? update.error.message : update.data?.state === 'current' ? '已是当前版本' : update.data ? '暂不能确认可升级版本' : '核对来源发布版本和安装指纹'}
onClick={() => { void update.refetch(); }}>{update.isFetching ? '检查中…' : update.isError ? '重试更新' : update.data?.state === 'current' ? '已是最新' : update.data ? '查看更新' : '检查更新'}</Button>}
<Button size="sm" variant="outline" disabled={!removable} onClick={() => { if (item) onManage(item, 'remove'); }}>{item?.state === 'missing' ? '清理记录' : '卸载'}</Button>
</>;
}

View file

@ -9,6 +9,9 @@ export function NativeControlStyles() {
color:var(--astron-sh-fg);font-family:var(--dsw-font-family,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei','PingFang SC',sans-serif);
font-size:14px;line-height:1.6;box-sizing:border-box;
}
.skillhub-controls .astron-sh-install-dialog-identity {display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid var(--astron-sh-border);margin-bottom:14px}
.skillhub-controls .astron-sh-install-dialog-identity p {margin:2px 0;color:var(--astron-sh-muted);font-size:12px}
.skillhub-controls .astron-sh-install-dialog-control {padding:0;border:0;background:transparent}
.skillhub-controls .astron-sh-label-filter { display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin:12px 0 4px;font-size:12px;line-height:18px; }
.skillhub-controls .astron-sh-label-filter > span { color:var(--astron-sh-muted);margin-right:4px; }
.skillhub-controls .astron-sh-label-filter button { min-height:28px;height:28px;padding:4px 10px;font-size:12px;border-radius:999px;margin:0;border:1px solid transparent; }
@ -58,11 +61,18 @@ export function NativeControlStyles() {
.skillhub-controls .astron-sh-skill-meta>.astron-sh-installation-status{max-width:76%}
.skillhub-controls .astron-sh-installation-status[data-tone=success]{background:light-dark(#eaf6ee,#19392c);color:light-dark(#187847,#7dddab)}
.skillhub-controls .astron-sh-installation-status[data-tone=warning]{background:light-dark(#fff3df,#3e3020);color:light-dark(#945414,#f0c27c)}
.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{margin-top:auto;padding:10px 17px;min-height:43px;border-top:1px solid var(--astron-sh-border);display:flex;justify-content:space-between;align-items:center;gap:8px}
.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{margin-top:auto;padding:10px 17px;min-height:43px;border-top:1px solid var(--astron-sh-border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.skillhub-controls .astron-sh-metrics{display:flex;gap:12px;align-items:center;font-size:12px;line-height:18px;color:var(--astron-sh-muted);font-variant-numeric:tabular-nums}.skillhub-controls .astron-sh-metrics>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
.skillhub-controls .astron-sh-metrics svg,.skillhub-controls .astron-sh-card-open svg{width:13px;height:13px;flex-shrink:0}
.skillhub-controls .astron-sh-card-open{position:static;padding:0;min-height:22px;height:22px;font-size:12px;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-metrics>span{position:relative;z-index:1}
.skillhub-controls .astron-sh-card-open:after{content:'';position:absolute;inset:0;border-radius:11px}
.skillhub-controls .astron-sh-card-open{position:absolute;inset:0;width:100%;height:100%;border:0;border-radius:11px;background:transparent;padding:0;z-index:1}
.skillhub-controls .astron-sh-card-open:not(:disabled):hover{background:transparent}
.skillhub-controls .astron-sh-card-open:focus-visible{outline:2px solid var(--astron-sh-accent);outline-offset:2px}
.skillhub-controls .astron-sh-card-actions{position:relative;z-index:2;display:flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end}
.skillhub-controls .astron-sh-card-actions button{height:28px;min-height:28px;padding:3px 9px;font-size:12px}
.skillhub-controls .astron-sh-card-actions .astron-sh-select-trigger{min-width:86px;gap:6px}
.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{min-height:43px;height:auto}
.skillhub-controls .astron-sh-load-more{display:flex;justify-content:center;align-items:center;gap:16px;margin-top:24px;font-size:12px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-empty,.skillhub-controls .astron-sh-error{padding:24px;border:1px solid var(--astron-sh-border);border-radius:10px;background:var(--astron-sh-soft);margin-block:16px}.skillhub-controls .astron-sh-empty{text-align:center}
.skillhub-controls .astron-sh-detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 290px;gap:32px;margin-top:24px}.skillhub-controls .astron-sh-detail-main{min-width:0;grid-column:1;grid-row:1}.skillhub-controls .astron-sh-detail-aside{grid-column:2;grid-row:1;min-width:0}
@ -74,6 +84,52 @@ export function NativeControlStyles() {
.skillhub-controls .astron-sh-dialog-overlay{position:fixed;inset:0;z-index:1000;background:color-mix(in srgb,#000 40%,transparent)}
.skillhub-controls .astron-sh-dialog{position:fixed;z-index:1001;top:50%;left:50%;transform:translate(-50%,-50%);width:min(460px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:auto;border:1px solid var(--astron-sh-border);border-radius:12px;padding:24px;background:var(--astron-sh-bg);color:var(--astron-sh-fg);box-shadow:0 16px 60px #0003}
.skillhub-controls .astron-sh-dialog h2{font-size:18px}.skillhub-controls .astron-sh-dialog p{font-size:14px;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-dialog dl{font-size:13px;padding:14px;background:var(--astron-sh-soft);border-radius:7px;overflow-wrap:anywhere}.skillhub-controls .astron-sh-dialog dt{color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-dialog dd{margin:0 0 8px}.skillhub-controls .astron-sh-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
.skillhub-controls .astron-sh-install-panel .astron-sh-scope-help{font-size:12px;line-height:1.5;margin:7px 0 16px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-current-install{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--astron-sh-border);border-radius:8px;background:var(--astron-sh-bg);margin:12px 0}
.skillhub-controls .astron-sh-current-install strong{font-size:13px;font-weight:500;color:light-dark(#218650,#7dddab)}
.skillhub-controls .astron-sh-current-install[data-state]:not([data-state=present]) strong{color:light-dark(#a76917,#f0c27c)}
.skillhub-controls .astron-sh-current-install button{min-height:28px;height:28px;padding:3px 10px;font-size:12px}
.skillhub-controls .astron-sh-other-install{font-size:12px;color:var(--astron-sh-muted);margin:12px 0}
.skillhub-controls .astron-sh-removal-identity{display:flex;align-items:center;gap:12px;margin:22px 0 18px;min-width:0}
.skillhub-controls .astron-sh-removal-identity>div{min-width:0;overflow-wrap:anywhere}
.skillhub-controls .astron-sh-removal-identity strong{font-size:15px;font-weight:600}
.skillhub-controls .astron-sh-removal-identity p{font-size:12px;margin:3px 0 0;overflow-wrap:anywhere}
.skillhub-controls .astron-sh-removal-scope{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;background:var(--astron-sh-soft);border-radius:8px;font-size:12px}
.skillhub-controls .astron-sh-removal-scope>span{color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-removal-scope strong{font-weight:500;font-size:13px}
.skillhub-controls .astron-sh-removal-dialog .astron-sh-removal-impact{margin:12px 0 0;font-size:12px;line-height:1.7}
.skillhub-controls .astron-sh-removal-dialog .astron-sh-dialog-actions button{min-height:32px;padding:5px 14px;font-size:13px}
.skillhub-controls .astron-sh-removal-dialog .astron-sh-dialog-actions .astron-sh-button-destructive{background:light-dark(#be3027,#f28b82);color:light-dark(#fff,#3b1410);border-color:transparent}
.skillhub-controls .astron-sh-removal-dialog .astron-sh-dialog-actions .astron-sh-button-destructive:not(:disabled):hover{filter:brightness(.94)}
.skillhub-controls .astron-sh-removal-status{margin-top:16px;font-size:13px}
.skillhub-controls .astron-sh-upgrade-versions{display:flex;align-items:center;justify-content:space-between;gap:12px;background:var(--astron-sh-soft);border-radius:8px;padding:14px;font-size:16px}
.skillhub-controls .astron-sh-upgrade-versions>strong{color:var(--astron-sh-accent);font-weight:600}
.skillhub-controls .astron-sh-upgrade-dialog .astron-sh-removal-impact{font-size:12px;margin:12px 0 0;line-height:1.7}
.skillhub-controls .astron-sh-version-action>button{width:100%;margin-top:4px}
.skillhub-controls .astron-sh-version-action>p{font-size:12px;line-height:1.7;margin:10px 0 0;color:var(--astron-sh-muted)}
/* Installation stays in one modal; the right column appears after submission. */
.skillhub-controls .astron-sh-dialog.astron-sh-dialog-expanded{width:min(800px,calc(100vw - 32px));left:clamp(16px,calc(50% - 230px),calc(100vw - 816px));transform:translateY(-50%)}
.skillhub-controls .astron-sh-install-dialog-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:24px;margin-top:18px}
.skillhub-controls .astron-sh-dialog-expanded .astron-sh-install-dialog-layout{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
.skillhub-controls .astron-sh-install-dialog-layout>div{min-width:0}
.skillhub-controls .astron-sh-dialog .astron-sh-install-dialog-control{padding:0;border:0;background:transparent;margin:0}
.skillhub-controls .astron-sh-install-dialog-layout>aside{min-width:0;border-left:1px solid var(--astron-sh-border);padding-left:24px}
.skillhub-controls .astron-sh-dialog .astron-sh-install-confirmed-scope{padding:12px 14px;margin:16px 0}
.skillhub-controls .astron-sh-install-confirmed-scope dd{margin:4px 0 0;font-weight:500}
.skillhub-controls .astron-sh-dialog .astron-sh-install-close-note{font-size:12px;line-height:1.7}
.skillhub-controls .astron-sh-install-progress-item{border:1px solid var(--astron-sh-border);border-radius:10px;background:var(--astron-sh-soft);padding:18px;min-height:200px}
.skillhub-controls .astron-sh-install-progress-item>header{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.skillhub-controls .astron-sh-install-progress-item>header>div{min-width:0;overflow-wrap:anywhere}
.skillhub-controls .astron-sh-install-progress-item>header strong{font-size:13px}
.skillhub-controls .astron-sh-install-progress-item>header p{margin:3px 0 0;font-size:12px}
.skillhub-controls .astron-sh-install-progress>strong{font-size:14px;font-weight:600}
.skillhub-controls .astron-sh-install-progress>p{font-size:12px;line-height:1.7;margin:10px 0 0}
.skillhub-controls .astron-sh-install-progress-track{height:6px;background:color-mix(in srgb,var(--astron-sh-accent) 12%,var(--astron-sh-bg));border-radius:999px;overflow:hidden;margin-top:14px}
.skillhub-controls .astron-sh-install-progress-track>span{display:block;width:38%;height:100%;border-radius:inherit;background:var(--astron-sh-accent);animation:astron-sh-installing 1.4s ease-in-out infinite}
.skillhub-controls .astron-sh-install-progress-track[data-state=complete]>span{width:100%;animation:none;background:light-dark(#218650,#7dddab)}
.skillhub-controls .astron-sh-install-progress-track[data-state=stopped]>span{animation:none;background:light-dark(#a76917,#f0c27c)}
@keyframes astron-sh-installing{0%,100%{transform:translateX(0)}50%{transform:translateX(160%)}}
@media(max-width:640px){.skillhub-controls .astron-sh-dialog-expanded .astron-sh-install-dialog-layout{grid-template-columns:minmax(0,1fr)}.skillhub-controls .astron-sh-install-dialog-layout>aside{border-left:0;border-top:1px solid var(--astron-sh-border);padding-left:0;padding-top:18px}}
.skillhub-controls .astron-sh-chat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:12px;padding:0;list-style:none}.skillhub-controls .astron-sh-chat-results>li{min-width:0}
.skillhub-controls.astron-sh-detail-drawer{background:var(--astron-sh-bg);color:var(--astron-sh-fg)}
@container(max-width:1000px){.skillhub-controls .astron-sh-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.skillhub-controls .astron-sh-detail-layout{grid-template-columns:minmax(0,1fr) 260px;gap:24px}}
@ -83,16 +139,16 @@ export function NativeControlStyles() {
.skillhub-controls .astron-sh-catalog-header{min-height:54px;margin-bottom:20px;border-bottom:1px solid var(--astron-sh-border);padding-bottom:12px;gap:12px}
.skillhub-controls .astron-sh-location{display:flex;align-items:center;gap:12px;font-size:13px}.skillhub-controls .astron-sh-location strong{font-size:15px;font-weight:600}.skillhub-controls .astron-sh-location>span{color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-location>svg{color:var(--astron-sh-accent)}
.skillhub-controls .astron-sh-toolbar-actions{display:flex;gap:6px}.skillhub-controls .astron-sh-icon-button{height:32px;min-height:32px;width:32px;padding:6px}.skillhub-controls .astron-sh-source-info{padding:10px 14px;border-radius:7px;background:var(--astron-sh-soft);font-size:12px;color:var(--astron-sh-muted);overflow-wrap:anywhere}
.skillhub-controls .astron-sh-skill-card{height:192px}.skillhub-controls .astron-sh-avatar{width:38px;height:38px;border-radius:9px}.skillhub-controls .astron-sh-avatar>span{font-size:19px}.skillhub-controls .astron-sh-skill-heading{padding:15px 16px 10px;gap:11px}.skillhub-controls .astron-sh-skill-heading h3{font-size:15px;line-height:20px;font-weight:600}.skillhub-controls .astron-sh-skill-heading p{font-size:12px;line-height:18px;margin-top:3px}.skillhub-controls .astron-sh-skill-body{padding:0 16px 10px}.skillhub-controls .astron-sh-skill-summary{font-size:13px;line-height:20px;min-height:40px;max-height:40px}.skillhub-controls .astron-sh-skill-meta{margin-top:8px;font-size:12px;line-height:18px}.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{padding:7px 15px;min-height:35px;height:35px}.skillhub-controls .astron-sh-metrics{font-size:12px;gap:11px}.skillhub-controls .astron-sh-metrics svg{width:12px;height:12px;color:color-mix(in srgb,var(--astron-sh-accent) 60%,var(--astron-sh-muted))}.skillhub-controls .astron-sh-card-open{color:var(--astron-sh-accent);font-size:12px}.skillhub-controls .astron-sh-detail-heading .astron-sh-avatar{width:46px;height:46px}
.skillhub-controls .astron-sh-skill-card{height:auto;min-height:210px}.skillhub-controls .astron-sh-avatar{width:38px;height:38px;border-radius:9px}.skillhub-controls .astron-sh-avatar>span{font-size:19px}.skillhub-controls .astron-sh-skill-heading{padding:15px 16px 10px;gap:11px}.skillhub-controls .astron-sh-skill-heading h3{font-size:15px;line-height:20px;font-weight:600}.skillhub-controls .astron-sh-skill-heading p{font-size:12px;line-height:18px;margin-top:3px}.skillhub-controls .astron-sh-skill-body{padding:0 16px 10px}.skillhub-controls .astron-sh-skill-summary{font-size:13px;line-height:20px;min-height:40px;max-height:40px}.skillhub-controls .astron-sh-skill-meta{margin-top:8px;font-size:12px;line-height:18px}.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{padding:7px 15px;min-height:43px;height:auto}.skillhub-controls .astron-sh-metrics{font-size:12px;gap:11px}.skillhub-controls .astron-sh-metrics svg{width:12px;height:12px;color:color-mix(in srgb,var(--astron-sh-accent) 60%,var(--astron-sh-muted))}.skillhub-controls .astron-sh-card-open{color:var(--astron-sh-accent);font-size:12px}.skillhub-controls .astron-sh-detail-heading .astron-sh-avatar{width:46px;height:46px}
.skillhub-controls .astron-sh-select-root{display:inline-flex;min-width:0}.skillhub-controls .astron-sh-select-trigger{width:100%;min-width:120px;display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;height:38px;border:1px solid var(--astron-sh-border);border-radius:7px;background:var(--astron-sh-bg);padding:8px 11px;text-align:start}.skillhub-controls .astron-sh-select-trigger>svg{width:14px;height:14px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-select-content{z-index:1100;max-height:var(--radix-select-content-available-height);min-width:var(--radix-select-trigger-width);overflow:hidden;border:1px solid var(--astron-sh-border);border-radius:8px;background:var(--astron-sh-bg);color:var(--astron-sh-fg);box-shadow:0 7px 24px #0002;padding:4px;font-size:13px}.skillhub-controls .astron-sh-select-item{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:8px 10px;border-radius:5px;cursor:default;outline:none}.skillhub-controls .astron-sh-select-item[data-highlighted]{background:var(--astron-sh-soft);color:var(--astron-sh-accent)}.skillhub-controls .astron-sh-select-item[data-state=checked]{color:var(--astron-sh-accent);font-weight:500}.skillhub-controls .astron-sh-select-scroll{display:flex;justify-content:center;height:20px;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-scope-field>.astron-sh-select-root{width:100%}
.skillhub-controls .astron-sh-select-content{z-index:1100;max-height:var(--radix-select-content-available-height);min-width:var(--radix-select-trigger-width);overflow:hidden;border:1px solid var(--astron-sh-border);border-radius:8px;background:var(--astron-sh-bg);color:var(--astron-sh-fg);box-shadow:0 7px 24px #0002;padding:4px;font-size:13px}.skillhub-controls .astron-sh-select-item{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:8px 10px;border-radius:5px;cursor:default;outline:none}.skillhub-controls .astron-sh-select-item[data-disabled]{opacity:.45;cursor:not-allowed}.skillhub-controls .astron-sh-select-item[data-highlighted]{background:var(--astron-sh-soft);color:var(--astron-sh-accent)}.skillhub-controls .astron-sh-select-item[data-state=checked]{color:var(--astron-sh-accent);font-weight:500}.skillhub-controls .astron-sh-select-scroll{display:flex;justify-content:center;height:20px;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-scope-field>.astron-sh-select-root{width:100%}
.skillhub-controls .astron-sh-reading-card{overflow:hidden;border-radius:11px;box-shadow:0 2px 5px #00000004}.skillhub-controls .astron-sh-reading-card .astron-sh-tabs-list{margin:0;padding:0 24px;gap:24px;height:50px;border-bottom:1px solid var(--astron-sh-border)}.skillhub-controls .astron-sh-reading-card .astron-sh-tabs-trigger{height:50px;padding:14px 0;font-size:13px;transition:none}.skillhub-controls .astron-sh-reading-card .astron-sh-tabs-trigger[data-state=active]{color:var(--astron-sh-accent)}.skillhub-controls .astron-sh-reading-card [role=tabpanel]{min-height:440px;display:flex;flex-direction:column;outline-offset:-3px}.skillhub-controls .astron-sh-reading-card [role=tabpanel][hidden]{display:none}.skillhub-controls .astron-sh-reading-body{padding:24px;flex:1;min-width:0;font-size:13px;line-height:23px}.skillhub-controls .astron-sh-reading-body>p{font-size:13px;line-height:23px}.skillhub-controls .astron-sh-reading-body h2{font-size:17px;line-height:25px}.skillhub-controls .astron-sh-reading-body h3{font-size:14px;line-height:22px}
.skillhub-controls .astron-sh-panel-footer{min-height:49px;margin-top:auto;padding:9px 24px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;border-top:1px solid var(--astron-sh-border);background:var(--astron-sh-soft);font-size:11px;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-panel-footer>div{display:flex;gap:8px;align-items:center}.skillhub-controls .astron-sh-panel-footer button{white-space:nowrap;font-size:12px;min-height:30px;padding:4px 8px}
.skillhub-controls .astron-sh-review-overview{display:flex;justify-content:space-between;align-items:center;padding-bottom:22px;border-bottom:1px solid var(--astron-sh-border);gap:12px}.skillhub-controls .astron-sh-review-overview>div{display:flex;align-items:center;gap:16px}.skillhub-controls .astron-sh-review-overview strong{font-size:32px;line-height:40px;font-weight:600}.skillhub-controls .astron-sh-review-overview p{font-size:11px;color:var(--astron-sh-muted);margin:6px 0 0}.skillhub-controls .astron-sh-read-only{white-space:nowrap;font-size:11px;background:var(--astron-sh-soft);border:1px solid var(--astron-sh-border);padding:1px 7px;border-radius:5px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-stars{display:inline-flex;align-items:center;gap:3px;color:var(--astron-sh-border);margin:7px 0}.skillhub-controls .astron-sh-stars svg{width:13px;height:13px}.skillhub-controls .astron-sh-stars [data-filled=true] svg{color:#b88d35;fill:currentColor}.skillhub-controls .astron-sh-rating-number{font-size:11px;color:var(--astron-sh-muted);margin-left:6px}.skillhub-controls .astron-sh-review{display:flex;align-items:flex-start;gap:12px;padding:22px 0;border-bottom:1px solid var(--astron-sh-border)}.skillhub-controls .astron-sh-review>.astron-sh-avatar{width:32px;height:32px;border-radius:50%}.skillhub-controls .astron-sh-review>.astron-sh-avatar>span{font-size:14px}.skillhub-controls .astron-sh-review>div{flex:1;min-width:0}.skillhub-controls .astron-sh-review header{display:flex;justify-content:space-between;gap:12px;align-items:center}.skillhub-controls .astron-sh-review header strong{font-size:13px;font-weight:500}.skillhub-controls .astron-sh-review time{font-size:11px;color:var(--astron-sh-muted);white-space:nowrap}.skillhub-controls .astron-sh-review-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:24px;margin:5px 0}.skillhub-controls .astron-sh-review-note{font-size:11px;line-height:18px;color:var(--astron-sh-muted);display:flex;gap:7px;align-items:flex-start;margin:18px 0 0}.skillhub-controls .astron-sh-review-note svg{width:12px;height:12px;margin-top:3px;flex-shrink:0}
.skillhub-controls .astron-sh-module-empty,.skillhub-controls .astron-sh-audit-empty{display:flex;flex-direction:column;align-items:center;text-align:center;justify-content:center;padding:34px 24px;color:var(--astron-sh-muted);gap:10px}.skillhub-controls .astron-sh-audit-empty>svg,.skillhub-controls .astron-sh-module-empty>svg{width:28px;height:28px;color:var(--astron-sh-accent);margin-bottom:8px}.skillhub-controls .astron-sh-audit-empty p{max-width:420px}.skillhub-controls .astron-sh-module-empty h3,.skillhub-controls .astron-sh-audit-empty h3{color:var(--astron-sh-fg);margin:0}
.skillhub-controls .astron-sh-version-row{display:flex;gap:15px;padding:4px 0 22px}.skillhub-controls .astron-sh-version-row>div{min-width:0;flex:1}.skillhub-controls .astron-sh-version-row h3{margin:0 0 8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}.skillhub-controls .astron-sh-version-dot{width:9px;height:9px;flex-shrink:0;border-radius:50%;background:var(--astron-sh-accent);margin-top:7px}.skillhub-controls .astron-sh-version-current{font-size:10px;font-weight:400;padding:0 6px;border-radius:4px;background:var(--astron-sh-soft);color:var(--astron-sh-accent)}.skillhub-controls .astron-sh-version-meta{font-size:11px;line-height:18px;color:var(--astron-sh-muted);margin-top:9px}
@container(max-width:680px){.skillhub-controls .astron-sh-skill-card{height:188px}.skillhub-controls .astron-sh-reading-card [role=tabpanel]{min-height:0}.skillhub-controls .astron-sh-reading-card .astron-sh-tabs-list{padding-inline:18px;gap:18px}.skillhub-controls .astron-sh-reading-body{padding:20px 18px}.skillhub-controls .astron-sh-panel-footer{padding-inline:18px}.skillhub-controls .astron-sh-review header{flex-direction:column;align-items:flex-start;gap:3px}.skillhub-controls .astron-sh-location{gap:8px;font-size:12px}}
@container(max-width:680px){.skillhub-controls .astron-sh-skill-card{height:auto;min-height:210px}.skillhub-controls .astron-sh-reading-card [role=tabpanel]{min-height:0}.skillhub-controls .astron-sh-reading-card .astron-sh-tabs-list{padding-inline:18px;gap:18px}.skillhub-controls .astron-sh-reading-body{padding:20px 18px}.skillhub-controls .astron-sh-panel-footer{padding-inline:18px}.skillhub-controls .astron-sh-review header{flex-direction:column;align-items:flex-start;gap:3px}.skillhub-controls .astron-sh-location{gap:8px;font-size:12px}}
@media(max-width:680px){.skillhub-controls.astron-sh-detail-drawer .astron-sh-reading-card [role=tabpanel]{min-height:0}.skillhub-controls.astron-sh-detail-drawer .astron-sh-reading-body{padding:20px 18px}.skillhub-controls.astron-sh-detail-drawer .astron-sh-review header{flex-direction:column;align-items:flex-start;gap:3px}}
.skillhub-controls .astron-sh-catalog-header{height:54px;min-height:54px;max-height:54px;align-items:center;flex-wrap:nowrap;padding-bottom:12px}

View file

@ -0,0 +1,6 @@
/** Read-only views may reuse data; write authorization is always checked separately. */
export const readCacheOptions = {
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
} as const;

View file

@ -1,11 +1,14 @@
import { canCollapseHistory } from './operation-presentation.js';
import { OperationDetails } from './operation-details.js';
import { HostWriteStatus, useWritableHost, type CompatibilityServices } from './host-compatibility.js';
import { useEffect, useId, useRef, useState, useSyncExternalStore } from 'react';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useMutation } from '@tanstack/react-query';
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
import type { HostExecutePlanRequest, HostRecoveryRequest, NativeOperation, UninstallPlanView, InstalledSkillList } from '../host/types.js';
import { type RemovalRequests, upgradeRequestKey } from './upgrade-requests.js';
import { Dialog, DialogContent, DialogTitle, DialogDescription, Button } from './ui.js';
import { SkillAvatar } from './skill-card.js';
import { NativeControlStyles } from './native-control-styles.js';
import { useInstallationOperation } from './use-installation-operation.js';
export interface RemovalServices extends CompatibilityServices {
@ -25,17 +28,18 @@ const blockers: Record<string, string> = {
'target-alias': '安装位置发生重定向,请先核对本地目录。',
};
export function RemovalControl({ services, sourceRevision, source, item, eligible = false, onCompleted, onClosed, history = false }: {
services: RemovalServices; sourceRevision: string; source: string; eligible?: boolean; history?: boolean;
item: { itemKey?: string; namespace: string; slug: string; scope: string; state?: InstalledSkillList['items'][number]['state'] }; onCompleted?(): void; onClosed?(): void;
export function RemovalControl({ services, sourceRevision, source, item, eligible = false, onCompleted, onClosed, history = false, presentation = 'panel' }: {
services: RemovalServices; sourceRevision: string; source: string; eligible?: boolean; history?: boolean; presentation?: 'panel' | 'dialog';
item: { itemKey?: string; namespace: string; slug: string; scope: string; version?: string; state?: InstalledSkillList['items'][number]['state'] }; onCompleted?(): void; onClosed?(): void;
}) {
const writableHost = useWritableHost(services);
const key = upgradeRequestKey(services.sessionId, source, item);
const attempt = useSyncExternalStore(services.removalRequests.subscribe, () => services.removalRequests.get(key), () => undefined);
const dialog = useRef<HTMLDialogElement>(null);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [dialogOpen, setDialogOpen] = useState(presentation === 'dialog');
const prepared = useRef(false);
const opener = useRef(typeof document === 'undefined' ? null : document.activeElement);
const trigger = useRef<HTMLButtonElement>(null);
const cancel = useRef<HTMLButtonElement>(null);
const titleId = useId();
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(Date.now);
const plan = useMutation({ mutationFn: async () => {
@ -46,13 +50,11 @@ export function RemovalControl({ services, sourceRevision, source, item, eligibl
return response.value;
} });
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (plan.data?.status === 'ready' && !attempt) {
if (!element.open) element.showModal();
cancel.current?.focus();
} else if (element.open) element.close();
}, [plan.data, attempt]);
if (presentation === 'dialog' && !prepared.current && writableHost.canWrite && eligible && !attempt) {
prepared.current = true;
plan.mutate();
}
}, [presentation, writableHost.canWrite, eligible, attempt, plan.mutate]);
useEffect(() => {
if (!plan.data?.expiresAt || attempt) return;
setNow(Date.now());
@ -92,34 +94,58 @@ export function RemovalControl({ services, sourceRevision, source, item, eligibl
const kind = result?.kind ?? plan.data?.kind;
const stale = kind ? kind === 'remove-stale-record' : item.state === 'missing';
const cancelPlan = () => {
dialog.current?.close();
plan.reset();
trigger.current?.focus();
if (presentation === 'dialog' && !operation.isError && operation.data && recoveryConfirmed) {
try { services.removalRequests.closeResult(key, operation.data); }
catch { setError('原结果暂不能关闭,请稍后核验。'); return; }
}
setDialogOpen(false); plan.reset();
if (presentation === 'dialog') onClosed?.();
else trigger.current?.focus();
};
const confirmation = <Dialog open={dialogOpen} onOpenChange={open => { if (!open) cancelPlan(); }}>
<DialogContent className="astron-sh-dialog astron-sh-removal-dialog" container={container} onOpenAutoFocus={event => {
event.preventDefault(); container?.querySelector<HTMLButtonElement>('[data-removal-cancel]')?.focus();
}} onCloseAutoFocus={event => { event.preventDefault(); if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus(); }}>
<DialogTitle>{stale ? '清理安装记录' : '卸载技能'}</DialogTitle>
<DialogDescription>{stale ? '仅清理所选范围的失效记录' : '确认要移除这项技能吗?'}</DialogDescription>
<RemovalConfirmationSummary item={item} version={plan.data?.currentVersion ?? result?.version ?? item.version} stale={stale} />
<HostWriteStatus state={writableHost} />
{!attempt && <>
{plan.isPending && <p role="status">正在检查安装状态…</p>}
{plan.isError && <p role="alert">{plan.error.message}</p>}
{plan.data?.status === 'blocked' && <p role="alert">{blockers[plan.data.reason] ?? '无法核验当前安装,请刷新记录后重试。'}</p>}
{expired && <p role="alert">确认已过期,请重新检查。</p>}
</>}
{attempt && <div className="astron-sh-removal-status" aria-live="polite">
{attempt.state === 'submitting' ? <p role="status">正在卸载…</p>
: attempt.state === 'unresolved' || operation.isError || operation.data === null ? <p role="alert">卸载结果暂未确认,请查询原请求。</p>
: result?.phase === 'completed' ? <p role="status">{operation.data?.phase === 'completed' ? stale ? '安装记录已清理' : '卸载完成' : '正在确认卸载结果…'}</p>
: result?.phase === 'failed' ? <p role="alert">卸载未完成,请检查安装状态。</p>
: result && ['recovery-required', 'unknown'].includes(result.phase) ? <p role="alert">卸载结果需要核验,请勿重复操作。</p> : <p role="status">正在卸载…</p>}
{!!result?.warningCount && <p role="alert">仍有残留需要核验。</p>}
{attempt.state === 'unresolved' && <Button size="sm" onClick={submit}>查询原请求</Button>}
{attempt.state === 'accepted' && (operation.isError || operation.data === null) && <Button size="sm" onClick={() => { void operation.refetch(); }}>查询原请求</Button>}
{result && (['recovery-required', 'unknown'].includes(result.phase) || (result.phase === 'completed' && result.warningCount > 0)) && <Button size="sm" disabled={!writableHost.canWrite || recovery.isPending} onClick={() => recovery.mutate()}>核验并恢复</Button>}
{recovery.isError && <p role="alert">{recovery.error.message}</p>}
</div>}
{error && <p role="alert">{error}</p>}
<div className="astron-sh-dialog-actions">
<Button data-removal-cancel onClick={cancelPlan}>{attempt ? '关闭' : '取消'}</Button>
{!attempt && (plan.isError || expired) && <Button disabled={plan.isPending || !writableHost.canWrite} onClick={() => plan.mutate()}>重新检查</Button>}
{!attempt && <Button variant="destructive" disabled={!writableHost.canWrite || expired || plan.isPending || plan.data?.status !== 'ready' || !eligible} onClick={submit}>{stale ? '确认清理' : '确认卸载'}</Button>}
</div>
</DialogContent>
</Dialog>;
if (presentation === 'dialog') return <div ref={setContainer} className="skillhub-controls"><NativeControlStyles />{container && confirmation}</div>;
const content = <section aria-label={`卸载操作 ${item.slug}`} style={{ border: '1px solid #8885', borderRadius: 12, padding: 16, marginBlock: 16 }}>
<h3>{stale ? '清理失效记录' : '卸载'} @{item.namespace}/{item.slug}</h3>
<HostWriteStatus state={writableHost} />
{!attempt && <>
<button ref={trigger} disabled={!writableHost.canWrite || plan.isPending || !eligible} onClick={() => { setError(null); plan.mutate(); }}>{plan.isPending ? '正在核验操作范围…' : stale ? '清理失效记录' : '卸载此安装位置'}</button>
<button ref={trigger} disabled={!writableHost.canWrite || plan.isPending || !eligible} onClick={() => { setError(null); setDialogOpen(true); plan.mutate(); }}>{plan.isPending ? '正在核验操作范围…' : stale ? '清理失效记录' : '卸载此安装位置'}</button>
{plan.isError && <p role="alert">{plan.error.message}</p>}
{plan.data?.status === 'blocked' && <p role="alert">{blockers[plan.data.reason] ?? '当前记录或目录无法通过核验,暂不能卸载。'}</p>}
</>}
<dialog ref={dialog} aria-labelledby={titleId} onCancel={event => { event.preventDefault(); cancelPlan(); }}
style={{ boxSizing: 'border-box', maxWidth: 'min(560px, calc(100vw - 32px))', maxHeight: 'calc(100vh - 32px)',
overflowY: 'auto', overflowWrap: 'anywhere', borderRadius: 12, padding: 24 }}>
<h3 id={titleId}>{stale ? '确认清理失效记录' : '确认卸载技能'}</h3>
<p>@{item.namespace}/{item.slug} · {plan.data?.currentVersion}</p>
<p style={{ overflowWrap: 'anywhere' }}>来源:{source}</p>
<p>目标主机:当前 DSH 主机</p>
<p>{plan.data?.target?.scope === 'user' ? '当前用户' : '当前项目'}:<code style={{ overflowWrap: 'anywhere' }}>{plan.data?.target?.path}</code></p>
<p>{stale ? '此位置的目录已缺失,本次只移除安装记录,不删除文件。' : '将移除此位置的技能文件和对应安装记录,其他安装位置和远端技能不受影响。'}</p>
<p>同名技能仍可能从其他位置提供;正在运行的任务不会被强制终止。</p>
{plan.data?.expiresAt && <p>确认有效至:<time dateTime={plan.data.expiresAt}>{new Date(plan.data.expiresAt).toLocaleString()}</time></p>}
{expired && <p role="alert">计划已过期,请取消后重新核验。</p>}
<button ref={cancel} onClick={cancelPlan}>取消</button>
<button className="skillhub-danger" disabled={!writableHost.canWrite || expired || plan.data?.status !== 'ready'} onClick={submit} style={{ marginInlineStart: 12 }}>{stale ? '仅清理失效记录' : '确认卸载'}</button>
{error && !attempt && <p role="alert">{error}</p>}
</dialog>
<div ref={setContainer}>{container && confirmation}</div>
{error && <p role="alert">{error}</p>}
{attempt?.state === 'submitting' && <p role="status">正在提交卸载请求…</p>}
{attempt?.state === 'unresolved' && <div role="alert"><p>请求结果尚未确认,请查询原请求。</p><button onClick={submit}>查询原卸载请求</button></div>}
@ -168,3 +194,13 @@ export function RemovalHistory({ services, sourceRevision, source, onClosed }: {
{entries.map(item => <RemovalControl key={item.key} services={services} sourceRevision={sourceRevision} source={source} item={item} onClosed={onClosed} history />)}
</section>;
}
/** Only information needed to confirm the selected target; no raw host paths or plan metadata. */
export function RemovalConfirmationSummary({ item, version, stale }: {
item: { namespace: string; slug: string; scope: string }; version?: string; stale: boolean;
}) {
return <><div className="astron-sh-removal-identity"><SkillAvatar namespace={item.namespace} slug={item.slug} /><div>
<strong>{item.slug}</strong><p>@{item.namespace}/{item.slug}{version ? ` · v${version}` : ''}</p></div></div>
<div className="astron-sh-removal-scope"><span>卸载范围</span><strong>{item.scope === 'user' ? '所有项目(当前用户)' : '仅此项目'}</strong></div>
<p className="astron-sh-removal-impact">{stale ? '仅移除失效记录,不删除文件。' : item.scope === 'user' ? '移除当前用户共享的技能,项目内的独立安装保留。' : '仅移除此项目的安装,用户级及其他项目的安装保留。'}</p></>;
}

View file

@ -1,6 +1,6 @@
import { forwardRef } from 'react';
import type { MouseEventHandler, ReactNode } from 'react';
import { Avatar, AvatarImage, AvatarFallback, Button, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui.js';
import { Avatar, AvatarImage, AvatarFallback, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui.js';
import { formatCount, skillInitial, skillTone, validCount, validRating } from './skill-presentation.js';
export function Icon({ name }: { name: 'download' | 'bookmark' | 'star' | 'arrow' | 'search' | 'grid' | 'info' | 'close' }) {
@ -35,15 +35,16 @@ export function SkillMetrics({ item }: { item: SkillPresentation }) {
}
export const SkillCard = forwardRef<HTMLButtonElement, {
item: SkillPresentation; version: string; onOpen: MouseEventHandler<HTMLButtonElement>; status?: ReactNode;
actionLabel?: string; accessibleLabel?: string; descriptionId?: string;
}>(({ item, version, onOpen, status, actionLabel = '详情', accessibleLabel, descriptionId }, ref) =>
actions?: ReactNode; accessibleLabel?: string; descriptionId?: string;
}>(({ item, version, onOpen, status, actions, accessibleLabel, descriptionId }, ref) =>
<Card className="astron-sh-skill-card">
<button ref={ref} type="button" className="astron-sh-card-open" onClick={onOpen}
aria-label={accessibleLabel || `查看 ${item.displayName || item.slug} @${item.namespace}/${item.slug} ${version} 详情`} aria-describedby={descriptionId} />
<CardHeader className="astron-sh-skill-heading"><SkillAvatar name={item.displayName} slug={item.slug} namespace={item.namespace} />
<div><CardTitle>{item.displayName || item.slug}</CardTitle><CardDescription title={`@${item.namespace}/${item.slug}`}>@{item.namespace}/{item.slug}</CardDescription></div>
</CardHeader>
<CardContent className="astron-sh-skill-body"><p id={descriptionId} className="astron-sh-skill-summary" title={item.summary}>{item.summary || '暂无摘要'}</p>
<div className="astron-sh-skill-meta"><span title={version}>v{version}</span>{status}</div></CardContent>
<CardFooter><SkillMetrics item={item} /><Button ref={ref} className="astron-sh-card-open" variant="ghost" onClick={onOpen}
aria-label={accessibleLabel || `查看 ${item.displayName || item.slug} @${item.namespace}/${item.slug} ${version} 详情`} aria-describedby={descriptionId}>{actionLabel}<Icon name="arrow" /></Button></CardFooter>
<CardFooter><SkillMetrics item={item} /><div className="astron-sh-card-actions">{actions}</div></CardFooter>
</Card>);
SkillCard.displayName = 'SkillCard';

View file

@ -70,7 +70,7 @@ export function DialogContent({ container, children, ...props }: React.Component
/** shadcn/ui Select (MIT), New York registry select.json; scoped CSS replaces utilities. */
export const SelectField = React.forwardRef<HTMLButtonElement, {
label: string; value: string; onValueChange(value: string): void; disabled?: boolean;
options: readonly { value: string; label: string }[];
options: readonly { value: string; label: string; disabled?: boolean }[];
}>(({ label, value, onValueChange, disabled, options }, ref) => {
const [container, setContainer] = React.useState<HTMLSpanElement | null>(null);
return <span className="astron-sh-select-root" ref={setContainer}>
@ -82,7 +82,7 @@ export const SelectField = React.forwardRef<HTMLButtonElement, {
<SelectPrimitive.Content position="popper" sideOffset={5} className="astron-sh-select-content">
<SelectPrimitive.ScrollUpButton className="astron-sh-select-scroll" aria-label="向上滚动">⌃</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport><SelectPrimitive.Group>
{options.map(option => <SelectPrimitive.Item key={option.value} value={option.value} className="astron-sh-select-item">
{options.map(option => <SelectPrimitive.Item key={option.value} value={option.value} disabled={option.disabled} className="astron-sh-select-item">
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator aria-hidden="true">✓</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>)}

View file

@ -8,17 +8,24 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
import type { HostExecutePlanRequest, HostRecoveryRequest, NativeOperation, UpgradePlanView } from '../host/types.js';
import type { UpgradeRequests } from './upgrade-requests.js';
import { upgradeRequestKey } from './upgrade-requests.js';
import { Button, Dialog, DialogContent, DialogTitle, DialogDescription } from './ui.js';
import { NativeControlStyles } from './native-control-styles.js';
import { SkillAvatar } from './skill-card.js';
import { useInstallationOperation } from './use-installation-operation.js';
export interface UpgradeServices extends CompatibilityServices {
sessionId: string;
upgradeRequests: UpgradeRequests;
prepareVersionUpgrade?(revision: string, itemKey: string, version: string, signal: AbortSignal): Promise<RemoteResult<UpgradePlanView>>;
prepareUpgrade(revision: string, itemKey: string, signal: AbortSignal): Promise<RemoteResult<UpgradePlanView>>;
executePlan(input: HostExecutePlanRequest, signal: AbortSignal): Promise<RemoteResult<NativeOperation | null>>;
operation(id: string, host: string): Promise<RemoteResult<NativeOperation | null>>;
recoverOperation(input: HostRecoveryRequest): Promise<RemoteResult<NativeOperation | null>>;
}
const blockers: Record<string, string> = {
'remote-older': '所选版本较旧,暂不支持降级。',
'unknown-order': '无法可靠比较版本,暂不能升级。',
'same-version-content-changed': '相同版本的内容已变化,请核实来源。',
'shared-target': '安装位置存在共享归属,暂不能升级。',
'target-outside-context': '完整安装记录包含当前会话无法管理的位置,不能只升级其中一部分。',
'target-alias': '安装位置重复或发生重定向,请先核对本地目录。',
@ -28,14 +35,18 @@ const blockers: Record<string, string> = {
'recovery-required': '有未完成的操作需要恢复核验,暂不能升级。',
};
export function UpgradeControl({ services, sourceRevision, source, item, eligible = false, onCompleted, onClosed, history = false }: {
services: UpgradeServices; sourceRevision: string; source: string; eligible?: boolean; history?: boolean;
export function UpgradeControl({ services, sourceRevision, source, item, eligible = false, onCompleted, onClosed, history = false, presentation = 'panel', targetVersion }: {
services: UpgradeServices; sourceRevision: string; source: string; eligible?: boolean; history?: boolean; presentation?: 'panel' | 'dialog'; targetVersion?: string;
item: { itemKey?: string; namespace: string; slug: string; version?: string; scope: string };
onCompleted?(version: string): void; onClosed?(): void;
}) {
const writableHost = useWritableHost(services);
const key = upgradeRequestKey(services.sessionId, source, item);
const attempt = useSyncExternalStore(services.upgradeRequests.subscribe, () => services.upgradeRequests.get(key), () => undefined);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [dialogOpen, setDialogOpen] = useState(presentation === 'dialog');
const prepared = useRef(false);
const opener = useRef(typeof document === 'undefined' ? null : document.activeElement);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(Date.now);
const trigger = useRef<HTMLButtonElement>(null);
@ -44,10 +55,18 @@ export function UpgradeControl({ services, sourceRevision, source, item, eligibl
const plan = useMutation({ mutationFn: async () => {
if (!writableHost.canWrite) throw new Error('当前 DSH 写入兼容性未通过核验。');
if (!item.itemKey) throw new Error('请从当前安装记录重新发起升级。');
const response = await services.prepareUpgrade(sourceRevision, item.itemKey, new AbortController().signal);
if (targetVersion && !services.prepareVersionUpgrade) throw new Error('当前版本不支持定向升级,请更新插件。');
const signal = new AbortController().signal;
const response = targetVersion ? await services.prepareVersionUpgrade!(sourceRevision, item.itemKey, targetVersion, signal)
: await services.prepareUpgrade(sourceRevision, item.itemKey, signal);
if (!response.ok) throw new Error('无法生成升级计划,请刷新安装记录后重试。');
return response.value;
} });
useEffect(() => {
if (presentation === 'dialog' && !prepared.current && eligible && writableHost.canWrite && !attempt) {
prepared.current = true; plan.mutate();
}
}, [presentation, eligible, writableHost.canWrite, attempt, plan.mutate]);
useLayoutEffect(() => {
if (plan.data) cancel.current?.focus();
else if (hadPlan.current) trigger.current?.focus();
@ -80,6 +99,7 @@ export function UpgradeControl({ services, sourceRevision, source, item, eligibl
const submit = () => {
const planId = attempt?.request.planId ?? plan.data?.planId;
const host = attempt?.hostInstanceId ?? plan.data?.hostInstanceId;
if (!attempt && targetVersion && plan.data?.candidateVersion !== targetVersion) return;
if ((!attempt && !writableHost.canWrite) || !planId || !host || (!attempt && (!plan.data?.expiresAt || Date.now() >= Date.parse(plan.data.expiresAt)))) return;
setError(null);
void services.upgradeRequests.submit(key, planId, host, async input => {
@ -89,6 +109,46 @@ export function UpgradeControl({ services, sourceRevision, source, item, eligibl
}).catch(error => setError(error instanceof Error ? error.message : '升级请求未提交。'));
};
if (!eligible && !attempt) return null;
function closeDialog() {
if (!operation.isError && operation.data && recoveryConfirmed) {
try { services.upgradeRequests.closeResult(key, operation.data); }
catch { setError('结果暂不能关闭,请稍后核验。'); return; }
}
setDialogOpen(false); plan.reset(); onClosed?.();
}
if (presentation === 'dialog') return <div ref={setContainer} className="skillhub-controls"><NativeControlStyles />{container && <Dialog open={dialogOpen} onOpenChange={open => { if (!open) closeDialog(); }}>
<DialogContent className="astron-sh-dialog astron-sh-upgrade-dialog" container={container} onOpenAutoFocus={event => { event.preventDefault(); cancel.current?.focus(); }}
onCloseAutoFocus={event => { event.preventDefault(); if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus(); }}>
<DialogTitle>升级技能</DialogTitle><DialogDescription>确认版本及更新范围后再执行</DialogDescription>
<UpgradeConfirmationSummary item={item} plan={plan.data} targetVersion={attempt ? result?.version : targetVersion} />
{attempt && result && targetVersion && result.version !== targetVersion && <p role="alert">当前显示此前升级结果,请关闭后重新确认所选版本。</p>}
<HostWriteStatus state={writableHost} />
{!attempt && <>
{plan.isPending && <p role="status">正在检查版本与安装文件…</p>}
{plan.isError && <p role="alert">{plan.error.message}</p>}
{plan.data && plan.data.status !== 'ready' && <p role={plan.data.status === 'unchanged' ? 'status' : 'alert'}>{plan.data.status === 'unchanged' ? '已是此版本,无需升级。' : blockers[plan.data.reason] ?? '当前安装无法升级,请检查安装状态。'}</p>}
{expired && <p role="alert">确认已过期,请重新检查。</p>}
{targetVersion && plan.data?.status === 'ready' && plan.data.candidateVersion !== targetVersion && <p role="alert">目标版本发生变化,请重新选择。</p>}
</>}
{attempt && <div className="astron-sh-removal-status" aria-live="polite">
{attempt.state === 'submitting' ? <p role="status">正在升级…</p>
: attempt.state === 'unresolved' || operation.isError || operation.data === null ? <p role="alert">升级结果暂未确认,请查询原请求。</p>
: result?.phase === 'completed' ? <p role="status">{operation.data?.phase === 'completed' ? `升级完成 · v${result.version}` : '正在确认升级结果…'}</p>
: result?.phase === 'failed' ? <p role="alert">升级未完成,{result.recovery === 'restored' ? '旧版本已恢复。' : '请核验当前安装。'}</p>
: result && ['unknown', 'recovery-required'].includes(result.phase) ? <p role="alert">升级需要恢复核验,请勿重复提交。</p> : <p role="status">正在升级…</p>}
{!!result?.warningCount && <p role="alert">升级有告警,请核验当前安装。</p>}
{attempt.state === 'unresolved' && <Button size="sm" onClick={submit}>查询原请求</Button>}
{attempt.state === 'accepted' && (operation.isError || operation.data === null) && <Button size="sm" onClick={() => { void operation.refetch(); }}>查询原请求</Button>}
{result && (['unknown', 'recovery-required'].includes(result.phase) || (result.phase === 'completed' && result.warningCount > 0)) && <Button size="sm" disabled={!writableHost.canWrite || recovery.isPending} onClick={() => recovery.mutate()}>核验并恢复</Button>}
{recovery.isError && <p role="alert">{recovery.error.message}</p>}
</div>}
{error && <p role="alert">{error}</p>}
<div className="astron-sh-dialog-actions"><Button ref={cancel} onClick={closeDialog}>{attempt ? '关闭' : '取消'}</Button>
{!attempt && (plan.isError || expired) && <Button disabled={!writableHost.canWrite || plan.isPending} onClick={() => plan.mutate()}>重新检查</Button>}
{!attempt && <Button variant="default" disabled={!eligible || !writableHost.canWrite || expired || plan.isPending || plan.data?.status !== 'ready' || (!!targetVersion && plan.data.candidateVersion !== targetVersion)} onClick={submit}>确认升级</Button>}
</div>
</DialogContent>
</Dialog>}</div>;
const content = <section aria-label="升级技能" style={{ border: '1px solid #8885', borderRadius: 12, padding: 16, marginBlock: 16 }}>
<h3>升级 @{item.namespace}/{item.slug}</h3>
<HostWriteStatus state={writableHost} />
@ -157,3 +217,11 @@ export function UpgradeHistory({ services, sourceRevision, source, onClosed }: {
{entries.map(item => <UpgradeControl key={item.key} services={services} sourceRevision={sourceRevision} source={source} item={item} onClosed={onClosed} history />)}
</section>;
}
export function UpgradeConfirmationSummary({ item, plan, targetVersion }: {
item: { namespace: string; slug: string; version?: string; scope: string }; plan: UpgradePlanView | undefined; targetVersion?: string;
}) {
return <><div className="astron-sh-removal-identity"><SkillAvatar namespace={item.namespace} slug={item.slug} /><div><strong>{item.slug}</strong><p>@{item.namespace}/{item.slug}</p></div></div>
<div className="astron-sh-upgrade-versions"><span>v{plan?.currentVersion ?? item.version}</span><span aria-label="升级到">→</span><strong>{plan?.candidateVersion || targetVersion ? `v${plan?.candidateVersion ?? targetVersion}` : '检查新版本…'}</strong></div>
<p className="astron-sh-removal-impact">{plan?.targets.length ? `将同步更新:${plan.targets.map(target => target.scope === 'user' ? '所有项目(当前用户)' : '仅此项目').join('、')}` : '更新前会核验已安装文件。'}</p></>;
}

View file

@ -20,21 +20,25 @@ const settled = (operation: NativeOperation | null | undefined) => !!operation
&& ['completed', 'failed', 'recovery-required', 'unknown'].includes(operation.phase);
export const shouldRefreshInstallation = (previous: NativeOperation | null | undefined, next: NativeOperation | null | undefined): boolean =>
settled(next) && previous?.phase !== next?.phase;
export function useInstallationOperation(services: Pick<InstallationServices, 'sessionId' | 'operation'>, host: string, operationId?: string) {
const client = useQueryClient();
const isVisible = useSyncExternalStore(watchVisibility, visible, () => true);
return useQuery({ queryKey: ['skillhub-operation', services.sessionId, host, operationId], retry: retryOperationQuery, retryDelay: attempt => 500 * (attempt + 1),
enabled: !!operationId && isVisible,
queryFn: async () => {
const previous = client.getQueryData<NativeOperation | null>(['skillhub-operation', services.sessionId, host, operationId]);
const response = await services.operation(operationId!, host);
if (!response.ok) {
const details = response.error.details;
throw new OperationQueryError(response.error.code === 'skillhub/installation-unavailable'
&& !!details && typeof details === 'object' && Reflect.get(details, 'retryable') === true);
}
if (settled(response.value)) {
if (shouldRefreshInstallation(previous, response.value)) {
// One shared operation query refreshes local projections for both page and tool consumers.
await invalidateInstallationQueries(client, services.sessionId);
void invalidateInstallationQueries(client, services.sessionId);
}
return response.value;
},

View file

@ -1,6 +1,7 @@
import Schema from '@deepseek-ai/schemastery';
import type { SkillHubConfig } from './types.js';
export const Config = Schema.object({
export const Config: Schema<{ registryUrl?: string | null; detailTabs?: Partial<SkillHubConfig['detailTabs']> | null }, SkillHubConfig, 'volatile-defined'> = Schema.object({
registryUrl: Schema.string().default('https://skill.xfyun.cn').description('SkillHub Registry base URL; defaults to https://skill.xfyun.cn; an explicit empty value disconnects the source.'),
detailTabs: Schema.object({
versions: Schema.union(['auto', 'show', 'hide']).default('auto'),

View file

@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { z } from 'zod';
const scope = z.enum(['user', 'project']).default('user');
const scope = z.enum(['user', 'project']).optional();
const coordinate = z.string().min(1).max(2048).refine(value =>
// eslint-disable-next-line no-control-regex -- Deliberately reject control characters in untrusted coordinates.
value !== '.' && value !== '..' && !/[\s/\\\u0000-\u001f\u007f]/u.test(value));
@ -28,3 +28,15 @@ export function conversationInstallKey(context: z.infer<typeof invocation>): str
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** A choice awaiting UI confirmation is not an accepted operation or a fingerprint lock. */
export interface PendingConversationInstall {
schemaVersion: 1; kind: 'skillhub-install'; status: 'awaiting-confirmation';
toolCallId: string; selectionRef: string; namespace: string; slug: string; version: string;
sourceLabel: string; sourceRevision: string; createdAt: string;
}
/** No installation was submitted after a human declined or skipped the scope question. */
export interface CancelledConversationInstall {
schemaVersion: 1; kind: 'skillhub-install'; status: 'cancelled'; toolCallId: string;
}

View file

@ -1,6 +1,7 @@
import { Context, symbols } from '@deepseek-ai/cordis';
import { Remote, RemoteError, RemoteScope, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
import type {} from '@deepseek-ai/dsh-agent';
import type {} from '@deepseek-ai/dsh-user-questions';
import type {} from '@deepseek-ai/dsh-session-query';
import { historicalSearchCandidate } from './conversation-history.js';
import type {} from '@deepseek-ai/dsh-sandbox-policy';
@ -28,6 +29,7 @@ import { observeDshInstallation } from './skill-discovery.js';
import { validateDshSkill } from './skill-format.js';
import { installRequest, operationQuery, executePlanRequest } from './install-request.js';
import { conversationInstallKey, conversationInstallRequest } from './conversation-install-request.js';
import type { PendingConversationInstall, CancelledConversationInstall } from './conversation-install-request.js';
import type { PreparedNativeInstall } from './local-runtime.js';
import type { NativeOperation, HostInstallRequest } from './types.js';
import type { NativeRecoveryResult } from './generated/native-contract.js';
@ -43,7 +45,7 @@ declare module '@deepseek-ai/cordis' {
/** Native SkillHub service with session-scoped installation and read-only catalog methods. */
export class SkillHubService extends TypertRemoteService {
static Config = Config;
static Config: typeof Config = Config;
private readonly configuration: ReturnType<typeof Config>;
private sourceRevision = randomUUID();
private registry?: RegistryAdapter;
@ -51,6 +53,8 @@ export class SkillHubService extends TypertRemoteService {
private readonly lifetime = new AbortController();
private localRuntime?: LocalRuntime;
private readonly selections = new ConversationSelections();
private readonly pendingConversationInstalls = new Map<string, { parameters: string; agent: unknown; runtime: LocalRuntime; result: Promise<PendingConversationInstall>; confirmation?: Promise<NativeOperation | PendingConversationInstall | CancelledConversationInstall | undefined> }>();
private readonly pendingConversationChoices = new Map<string, { agent: unknown; runtime: LocalRuntime }>();
private readonly installationTargets = new InstallationTargets();
private readonly upgradePlans = new UpgradePlans();
private readonly removalPlans = new RemovalPlans();
@ -80,9 +84,9 @@ export class SkillHubService extends TypertRemoteService {
});
ctx.inject(['tools'], ctx => {
ctx.effect(() => registerSearchTool(ctx.tools, (query, context, signal) => this.searchConversation(query, context, signal)));
ctx.effect(() => registerInstallTool(ctx.tools, (request, context, signal) => this.installConversation(request, context, signal)));
ctx.effect(() => registerInstallTool(ctx.tools, (request, context, signal) => this.installConversationWithChoice(request, context, signal)));
});
ctx.effect(() => () => { this.lifetime.abort(); this.details.clear(); this.capabilities.clear(); this.selections.clear(); this.installationTargets.clear(); this.upgradePlans.clear(); this.removalPlans.clear(); });
ctx.effect(() => () => { this.lifetime.abort(); this.pendingConversationInstalls.clear(); this.pendingConversationChoices.clear(); this.details.clear(); this.capabilities.clear(); this.selections.clear(); this.installationTargets.clear(); this.upgradePlans.clear(); this.removalPlans.clear(); });
}
/** Internal diagnostics, deliberately not a Remote method. */
@ -247,7 +251,7 @@ export class SkillHubService extends TypertRemoteService {
async listVersions(detailToken: string, page: number, signal: AbortSignal): Promise<VersionPage> {
const detail = this.readDetail(detailToken, 'versions');
return this.readSource(detail.sourceRevision, signal,
(registry, signal) => registry.versions(detail.namespace, detail.slug, page, 20, signal));
(registry, signal) => registry.browseVersions(detail.namespace, detail.slug, page, 20, signal));
}
@Remote
@ -332,16 +336,26 @@ export class SkillHubService extends TypertRemoteService {
@RemoteScope('agent')
async checkUpdate(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise<InstalledUpdateCheck> {
return this.checkUpdateForVersion(sourceRevision, itemKey, undefined, signal);
}
@RemoteScope('agent')
async checkVersionUpdate(sourceRevision: string, itemKey: string, version: string, signal: AbortSignal): Promise<InstalledUpdateCheck> {
return this.checkUpdateForVersion(sourceRevision, itemKey, version, signal);
}
private async checkUpdateForVersion(sourceRevision: string, itemKey: string, version: string | undefined, signal: AbortSignal): Promise<InstalledUpdateCheck> {
const { item, valid } = await this.readInstalledItem(sourceRevision, itemKey, signal);
const base = { currentVersion: item.version, candidateVersion: null, checkedAt: new Date().toISOString() };
if (item.state !== 'present') return { ...base, state: 'blocked', reason: 'local-record-unverified' };
let candidate;
try {
candidate = await this.readSource(sourceRevision, signal, (registry, signal) => registry.resolve(item.namespace, item.slug, undefined, signal));
candidate = await this.readSource(sourceRevision, signal, (registry, signal) => registry.resolve(item.namespace, item.slug, version, signal));
} catch (error) {
signal.throwIfAborted(); valid();
return { ...base, state: 'unavailable', reason: installationFailureCode(error) };
}
if (version !== undefined && candidate.version !== version) throw new RemoteError('skillhub/context-stale', 'Selected version changed.', { retryable: false });
const after = await this.readInstalledItem(sourceRevision, itemKey, signal);
if (after.item.state !== item.state || after.item.fingerprint !== item.fingerprint) {
throw new RemoteError('skillhub/context-stale', 'Installed record changed during update check.', { retryable: false });
@ -355,6 +369,15 @@ export class SkillHubService extends TypertRemoteService {
@RemoteScope('agent')
async prepareUpgrade(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise<UpgradePlanView> {
return this.prepareUpgradeForVersion(sourceRevision, itemKey, undefined, signal);
}
@RemoteScope('agent')
async prepareVersionUpgrade(sourceRevision: string, itemKey: string, version: string, signal: AbortSignal): Promise<UpgradePlanView> {
return this.prepareUpgradeForVersion(sourceRevision, itemKey, version, signal);
}
private async prepareUpgradeForVersion(sourceRevision: string, itemKey: string, version: string | undefined, signal: AbortSignal): Promise<UpgradePlanView> {
const { item, valid, agent, runtime, fs } = await this.readInstalledItem(sourceRevision, itemKey, signal);
const combined = AbortSignal.any([signal, this.lifetime.signal]);
const context = this.selectionScope(agent!);
@ -373,7 +396,8 @@ export class SkillHubService extends TypertRemoteService {
valid(); combined.throwIfAborted();
if (baseline.status === 'blocked') return { ...base, reason: baseline.reason };
const candidate = await this.readSource(sourceRevision, combined,
(registry, signal) => registry.resolve(item.namespace, item.slug, undefined, signal));
(registry, signal) => registry.resolve(item.namespace, item.slug, version, signal));
if (version !== undefined && candidate.version !== version) throw new RemoteError('skillhub/context-stale', 'Selected version changed.', { retryable: false });
const assessment = await runtime!.assessUpdate(baseline.inventoryItem, candidate);
valid(); combined.throwIfAborted();
if (assessment.state !== 'upgradable') return { ...base,
@ -596,6 +620,8 @@ export class SkillHubService extends TypertRemoteService {
const snapshot = this.details.get(detail.detailToken);
if (!snapshot) throw new RemoteError('skillhub/context-stale', 'Detail expired.', { retryable: false });
const fixedRef = this.selections.stage(selectionRef, this.selectionScope(agent), detail.detailToken, snapshot);
const pendingOrigin = this.pendingConversationChoices.get(selectionRef);
if (pendingOrigin) this.pendingConversationChoices.set(fixedRef, pendingOrigin);
return { ...detail, selectionRef: fixedRef };
}
@ -814,8 +840,61 @@ export class SkillHubService extends TypertRemoteService {
};
}
/** Use the Host question surface without creating an operation until a human answers. */
async installConversationWithChoice(input: unknown, context: ConversationSearchContext, signal: AbortSignal): Promise<NativeOperation | PendingConversationInstall | CancelledConversationInstall | undefined> {
const request = conversationInstallRequest.parse(input);
const result = await this.installConversation(request, context, signal);
if (request.scope || !result || !('status' in result)) return result;
const questions = this.ctx.get('userQuestions');
if (!questions) return result;
const runtime = this.localRuntime;
const agent = this.ctx.get('agents')?.get(SessionId(context.sessionId));
const hostInstanceId = runtime?.getStatus().hostInstanceId;
if (!runtime || !agent || !hostInstanceId) throw new RemoteError('skillhub/context-stale', 'Conversation changed.', { retryable: false });
const requestKey = conversationInstallKey({ hostInstanceId, sessionId: context.sessionId, callId: context.callId });
const entry = this.pendingConversationInstalls.get(requestKey)!;
if (!entry.confirmation) entry.confirmation = (async () => {
const selectionScope = this.selectionScope(agent);
this.selections.read(result.selectionRef, selectionScope);
const detail = await this.getSkillDetail(result.sourceRevision, result.namespace, result.slug, result.version, signal);
const available = await this.installationTargetsFor(context.sessionId, detail.detailToken, signal);
if (!available.targets.length) throw new RemoteError('skillhub/target-unavailable', 'No installation target available.', { retryable: false });
const labels = { project: '安装到当前项目', user: '安装到当前用户' };
let answer;
try {
answer = await questions.ask({ agent, signal, questions: [{ id: 'skillhub-install-scope', header: '安装范围',
question: `安装 @${result.namespace}/${result.slug} · ${result.version} 到哪里?`,
detail: `技能:${detail.title}\n\n版本:${detail.version}\n\n来源:${result.sourceLabel}\n\n选择范围并提交即确认安装。项目范围不会自动同步给协作者。`,
options: [...[...available.targets].sort((a, b) => Number(b.scope === 'project') - Number(a.scope === 'project')).map(target => ({ label: labels[target.scope], description: target.scope === 'project'
? '仅当前工作区使用,不自动共享或同步给协作者。' : '此 DSH 主机上当前用户的多个项目可用。' })),
{ label: '取消安装', description: '不下载技能包,不写入技能文件。' }], multiSelect: false }] });
} catch (error) {
if (error && typeof error === 'object' && Reflect.get(error, 'code') === 'NO_PROVIDER') return result;
if (!signal.aborted && error && typeof error === 'object' && Reflect.get(error, 'code') === 'ASK_CANCELLED') {
return { schemaVersion: 1 as const, kind: 'skillhub-install' as const, status: 'cancelled' as const, toolCallId: context.callId };
}
throw error;
}
signal.throwIfAborted();
const choice = answer.answers.find(item => item.id === 'skillhub-install-scope');
const target = !choice?.custom && choice?.selected.length === 1
? available.targets.find(target => labels[target.scope] === choice.selected[0]) : undefined;
if (!target) return { schemaVersion: 1 as const, kind: 'skillhub-install' as const, status: 'cancelled' as const, toolCallId: context.callId };
// Recheck every binding after the potentially long human wait. Never resolve latest again.
this.assertSource(result.sourceRevision);
this.selections.read(result.selectionRef, this.selectionScope(agent));
if (this.localRuntime !== runtime || this.ctx.get('agents')?.get(SessionId(context.sessionId)) !== agent || agent.session.header.cwd !== context.cwd) {
throw new RemoteError('skillhub/context-stale', 'Conversation changed.', { retryable: false });
}
return runtime.submitInstall({ ownerId: context.sessionId, hostInstanceId, requestKey, mode: 'new',
parameters: { ...request, scope: target.scope, cwd: context.cwd, initiatedFrom: 'conversation' } },
() => this.prepareInstallation(context.sessionId, detail.detailToken, target.targetToken, 'conversation', signal));
})();
return entry.confirmation;
}
/** Stable tool identity is checked in the journal before resolving ephemeral detail/target references. */
async installConversation(input: unknown, context: ConversationSearchContext, signal: AbortSignal): Promise<NativeOperation | undefined> {
async installConversation(input: unknown, context: ConversationSearchContext, signal: AbortSignal): Promise<NativeOperation | PendingConversationInstall | undefined> {
const request = conversationInstallRequest.parse(input);
const runtime = this.localRuntime;
const agent = this.ctx.get('agents')?.get(SessionId(context.sessionId));
@ -824,6 +903,55 @@ export class SkillHubService extends TypertRemoteService {
throw new RemoteError('skillhub/context-stale', 'Installation context unavailable.', { retryable: false });
}
const requestKey = conversationInstallKey({ hostInstanceId, sessionId: context.sessionId, callId: context.callId });
const parameters = JSON.stringify({ ...request, cwd: context.cwd });
const pending = this.pendingConversationInstalls.get(requestKey);
if (pending) {
if (pending.agent !== agent || pending.runtime !== runtime) throw new RemoteError('skillhub/context-stale', 'Conversation changed.', { retryable: false });
if (pending.parameters !== parameters) throw new RemoteError('skillhub/request-conflict', 'Request parameters changed.', { retryable: false });
const result = await pending.result;
signal.throwIfAborted();
this.selections.read(result.selectionRef, this.selectionScope(agent));
if (this.localRuntime !== runtime || this.ctx.get('agents')?.get(SessionId(context.sessionId)) !== agent) {
throw new RemoteError('skillhub/context-stale', 'Conversation changed.', { retryable: false });
}
return result;
}
if (!request.scope) {
// No transaction, target token, download, or fingerprint is created before visible confirmation.
const result = (async (): Promise<PendingConversationInstall> => {
signal.throwIfAborted();
const scope = this.selectionScope(agent);
let selectionRef: string;
let candidate: { namespace: string; slug: string; version: string };
if ('selectionRef' in request) {
const { namespace, slug, version } = this.selections.read(request.selectionRef, scope);
candidate = { namespace, slug, version };
selectionRef = this.selections.add({ ...context, sourceRevision: scope.sourceRevision }, candidate);
} else {
const resolved = await this.readSource(scope.sourceRevision, signal,
(registry, signal) => registry.resolve(request.namespace, request.slug, undefined, signal));
candidate = { namespace: request.namespace, slug: request.slug, version: resolved.version };
selectionRef = this.selections.add({ ...context, sourceRevision: scope.sourceRevision }, candidate);
}
signal.throwIfAborted();
this.assertSource(scope.sourceRevision);
if (agent.session.header.cwd !== context.cwd || this.localRuntime !== runtime
|| this.ctx.get('agents')?.get(SessionId(context.sessionId)) !== agent) {
throw new RemoteError('skillhub/context-stale', 'Conversation changed.', { retryable: false });
}
this.pendingConversationChoices.set(selectionRef, { agent, runtime });
return { schemaVersion: 1, kind: 'skillhub-install', status: 'awaiting-confirmation',
toolCallId: context.callId, selectionRef, namespace: candidate.namespace, slug: candidate.slug,
version: candidate.version, sourceLabel: this.getContext().registryUrl,
sourceRevision: scope.sourceRevision, createdAt: new Date().toISOString() };
})();
// Retain a failed choice too: retrying the call must never silently resolve a newer candidate.
this.pendingConversationInstalls.set(requestKey, { parameters, agent, runtime, result });
return result;
}
if ('selectionRef' in request && this.pendingConversationChoices.has(request.selectionRef)) {
throw new RemoteError('skillhub/context-stale', 'Choose the scope and confirm in the installation card.', { retryable: false });
}
return runtime.submitInstall({ ownerId: context.sessionId, hostInstanceId, requestKey, mode: 'new',
parameters: { ...request, cwd: context.cwd, initiatedFrom: 'conversation' } }, async () => {
signal.throwIfAborted();
@ -953,6 +1081,10 @@ export class SkillHubService extends TypertRemoteService {
}
private conversationSelection(agent: { session: { id: string; header: { cwd?: string } } }, reference: string) {
const pending = this.pendingConversationChoices.get(reference);
if (pending && (pending.agent !== agent || pending.runtime !== this.localRuntime)) {
throw new RemoteError('skillhub/context-stale', 'Installation choice expired. Search again.', { retryable: false });
}
return this.selections.read(reference, this.selectionScope(agent));
}

View file

@ -1,7 +1,7 @@
import { defineTool } from '@deepseek-ai/dsh-tools';
import type { Context } from '@deepseek-ai/cordis';
import { conversationInstallRequest } from './conversation-install-request.js';
import type { ConversationInstallRequest } from './conversation-install-request.js';
import type { PendingConversationInstall, CancelledConversationInstall, ConversationInstallRequest } from './conversation-install-request.js';
import type { ConversationSearchContext } from './search-tool.js';
import type { NativeOperation } from './types.js';
import { installationFailureCode, installationFailureCodes } from './installation-failure.js';
@ -19,6 +19,14 @@ const acceptedSchema = {
},
} as const;
export const installOutputSchema = { oneOf: [acceptedSchema, {
type: 'object', additionalProperties: false, properties: {
schemaVersion: { type: 'integer', const: 1, required: true },
kind: { type: 'string', const: 'skillhub-install', required: true },
status: { type: 'string', const: 'awaiting-confirmation', required: true },
toolCallId: string, selectionRef: string, namespace: string, slug: string, version: string,
sourceLabel: string, sourceRevision: string, createdAt: string,
},
}, {
type: 'object', additionalProperties: false, properties: {
schemaVersion: { type: 'integer', const: 1, required: true },
kind: { type: 'string', const: 'skillhub-install', required: true },
@ -26,24 +34,34 @@ export const installOutputSchema = { oneOf: [acceptedSchema, {
toolCallId: string,
code: { type: 'string', enum: installationFailureCodes, required: true },
},
}, {
type: 'object', additionalProperties: false, properties: {
schemaVersion: { type: 'integer', const: 1, required: true },
kind: { type: 'string', const: 'skillhub-install', required: true },
status: { type: 'string', const: 'cancelled', required: true }, toolCallId: string,
},
}] } as const;
export function registerInstallTool(tools: Pick<Context['tools'], 'get' | 'register'>, install: (
request: ConversationInstallRequest, context: ConversationSearchContext, signal: AbortSignal,
) => Promise<NativeOperation | undefined>) {
) => Promise<NativeOperation | PendingConversationInstall | CancelledConversationInstall | undefined>) {
if (tools.get(INSTALL_TOOL_NAME)) throw new Error('SkillHub install tool name is already registered.');
return tools.register(defineTool({
name: INSTALL_TOOL_NAME,
description: 'Install a SkillHub skill only when the user explicitly requests installation. Searching or asking about a skill is not an install request. If multiple candidates match and the user says "that skill", "the document skill", or otherwise does not uniquely identify one, ask which namespace/slug or result number they mean and stop without calling this tool. Never pick the first, a random, or an arbitrary selectionRef. An explicit request to install does not resolve an ambiguous target. Keep selectionRef, toolCallId, operationId, hostInstanceId, and sourceRevision internal to tool calls and correlation; never display them or ask the user to copy them. Describe the skill by namespace/slug, version, and current status. For a prior search/detail choice use its selectionRef; opening the detail UI is not required, because the Host verifies the exact selected version before installation; never replace an expired or missing reference with coordinates. Use namespace and slug only for a new complete coordinate explicitly supplied by the user. User scope is the default; use project only when requested. This tool does not upgrade, uninstall, or run skills. An accepted result is not installation success. The DSH installation result card checks completion for the user; there is no operation-query tool. After acceptance, summarize that installation has started and direct the user to that card. Do not use shell commands, filesystem inspection, invented tools, or another install call to check progress.',
description: 'Install a SkillHub skill only when the user explicitly requests installation. Searching or asking about a skill is not an install request. If multiple candidates match and the user says "that skill", "the document skill", or otherwise does not uniquely identify one, ask which namespace/slug or result number they mean and stop without calling this tool. Never pick the first, a random, or an arbitrary selectionRef. An explicit request to install does not resolve an ambiguous target. Keep selectionRef, toolCallId, operationId, hostInstanceId, and sourceRevision internal to tool calls and correlation; never display them or ask the user to copy them. Describe the skill by namespace/slug, version, and current status. For a prior search/detail choice use its selectionRef; when the user explicitly specified a scope, opening the detail UI is not required because the Host verifies the exact selected version before installation; never replace an expired or missing reference with coordinates. Use namespace and slug only for a new complete coordinate explicitly supplied by the user. Pass scope only when the user explicitly specifies current user or current project. When the user has not specified scope, omit it: the Host asks the user to choose and confirm next to the chat input; if that UI is unavailable the tool returns awaiting-confirmation with a confirmation card. Never promise installation before the human answers. Never invent a scope or call the tool again to bypass UI confirmation. A later user message explicitly choosing a scope may initiate a new call using the original still-valid search reference; never use the dedicated pending UI reference for that call. This tool does not upgrade, uninstall, or run skills. An accepted result is not installation success. The DSH installation result card checks completion for the user; there is no operation-query tool. After acceptance, summarize that installation has started and direct the user to that card. Do not use shell commands, filesystem inspection, invented tools, or another install call to check progress.',
parameters: {
selectionRef: { type: 'string', description: 'Host-issued reference for the selected search/detail item. Mutually exclusive with namespace and slug.' },
namespace: { type: 'string', description: 'Namespace of a newly specified complete coordinate. Requires slug; no selectionRef.' },
slug: { type: 'string', description: 'Slug of a newly specified complete coordinate. Requires namespace; no selectionRef.' },
scope: { type: 'string', enum: ['user', 'project'], description: 'Install scope; defaults to user. Never supply file paths.' },
scope: { type: 'string', enum: ['user', 'project'], description: 'Only supply user/project when explicitly requested by the user; otherwise omit to show the UI scope confirmation. Never supply file paths.' },
},
output: {
schema: installOutputSchema,
render: (_args, result) => [{ type: 'text', text: JSON.stringify(result) + '\nInternal references (selectionRef, toolCallId, operationId, hostInstanceId, sourceRevision) are for tool use and correlation only. Do not display them or ask the user to copy them; report skill coordinates, version, and status instead.' + (result.status === 'accepted'
render: (_args, result) => [{ type: 'text', text: JSON.stringify(result.status === 'awaiting-confirmation' ? { ...result, selectionRef: undefined } : result) + '\nInternal references (selectionRef, toolCallId, operationId, hostInstanceId, sourceRevision) are for tool use and correlation only. Do not display them or ask the user to copy them; report skill coordinates, version, and status instead.' + (result.status === 'awaiting-confirmation'
? '\nNot installed: waiting for visible scope confirmation. Ask the user to choose current project or current user and confirm in this result card. Do not announce installation has started or succeeded. Do not call this tool again to substitute for UI confirmation unless a later user message explicitly chooses a scope; that new call must use the original still-valid search reference, not the pending UI reference. The user interface reads and displays the selected exact version before confirmation; no fingerprint or installation authorization is fixed yet.'
: result.status === 'cancelled'
? '\nInstallation was not submitted. The user cancelled, skipped, or did not choose an exact supported scope. Do not retry without a new explicit request.'
: result.status === 'accepted'
? '\nInstallation request accepted; this is not proof of successful installation or skill execution. The DSH installation result card automatically checks and displays the current outcome. Tell the user installation has started and to check that card. No operation-query tool is available. Do not call shell, read files, invent a status tool, or submit installation again to check progress.'
: '\nInstallation acceptance was not confirmed. Do not create a new tool call to retry or substitute coordinates for an expired reference. Explain the code and check the original operation or reopen the skill detail.') }],
presentationMeta: (_args, result) => result,
@ -53,7 +71,7 @@ export function registerInstallTool(tools: Pick<Context['tools'], 'get' | 'regis
if (!parsed.success) throw new Error('Provide either selectionRef or namespace and slug, plus optional user/project scope. No other fields are allowed.');
const session = exec.agent?.session;
if (!session?.id || !session.header.cwd || !exec.callId) throw new Error('SkillHub install requires a trusted conversation context.');
let operation: NativeOperation | undefined;
let operation: NativeOperation | PendingConversationInstall | CancelledConversationInstall | undefined;
try {
operation = await install(parsed.data, { sessionId: session.id, cwd: session.header.cwd, callId: exec.callId }, exec.signal);
} catch (error) {
@ -63,6 +81,7 @@ export function registerInstallTool(tools: Pick<Context['tools'], 'get' | 'regis
}
if (!operation) return { schemaVersion: 1 as const, kind: 'skillhub-install' as const, status: 'not-confirmed' as const,
toolCallId: exec.callId, code: 'UNCONFIRMED' as const };
if ('status' in operation) return operation;
return { schemaVersion: 1 as const, kind: 'skillhub-install' as const, status: 'accepted' as const,
toolCallId: exec.callId, operationId: operation.operationId, hostInstanceId: operation.context.hostInstanceId,
namespace: operation.namespace, slug: operation.slug, version: operation.version, targetLabel: operation.targetLabel,

View file

@ -169,6 +169,17 @@ export class RegistryAdapter {
return this.page(`/api/v1/skills/${segment(namespace)}/${segment(slug)}/versions`, versionSchema, page, size, signal);
}
/** Public browser query: the Registry filters and orders before pagination. */
async browseVersions(namespace: string, slug: string, page: number, size: number, signal?: AbortSignal) {
if (!Number.isSafeInteger(page) || page < 0 || !Number.isSafeInteger(size) || size < 1 || size > 20) {
throw new RegistryError('INVALID_INPUT', false);
}
const params = new URLSearchParams({ page: String(page), size: String(size), downloadableOnly: 'true' });
const result = await this.json(`/api/v1/skills/${segment(namespace)}/${segment(slug)}/versions?${params}`, pageSchema(versionSchema), signal);
if (result.page !== page || result.size !== size) throw new RegistryError('INVALID_RESPONSE', false);
return result;
}
async reviews(skillId: number, page: number, size: number, signal?: AbortSignal) {
if (!Number.isSafeInteger(skillId) || skillId < 1) throw new RegistryError('INVALID_INPUT', false);
const result = await this.page(`/api/v1/skills/${skillId}/reviews`, reviewSchema, page, size, signal);

View file

@ -4,7 +4,8 @@ import { assertHostDependencies, hostDependencyBaseline } from '../src/host/comp
test('Host dependency guard matches exact peer versions and installed development artifacts', async () => {
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
expect({ ...hostDependencyBaseline, '@deepseek-ai/cordis': '4.0.4 || 4.0.5' }).toEqual(manifest.peerDependencies);
expect(manifest.peerDependenciesMeta).toEqual({ '@deepseek-ai/dsh-user-questions': { optional: true } });
expect(Object.fromEntries(Object.entries(manifest.peerDependencies).filter(([name]) => !manifest.peerDependenciesMeta?.[name]?.optional))).toEqual({ ...hostDependencyBaseline, '@deepseek-ai/cordis': '4.0.4 || 4.0.5' });
expect(() => assertHostDependencies()).not.toThrow();
});

View file

@ -21,15 +21,15 @@ test('conversation retries reach durable acceptance before expired reference res
try {
const context = { sessionId: 'owner', cwd: '/project', callId: 'install-call' };
const signal = new AbortController().signal;
await ctx.skillhub.installConversation({ selectionRef: 'expired' }, context, signal);
await ctx.skillhub.installConversation({ selectionRef: 'expired' }, context, signal);
await ctx.skillhub.installConversation({ selectionRef: 'expired', scope: 'user' }, context, signal);
await ctx.skillhub.installConversation({ selectionRef: 'expired', scope: 'user' }, context, signal);
await ctx.skillhub.installConversation({ selectionRef: 'changed', scope: 'project' }, context, signal);
expect(requests).toHaveLength(3);
expect(requests[0]).toEqual(requests[1]);
expect(requests[2]!.requestKey).toBe(requests[0]!.requestKey);
expect(requests[0]!.parameters).toEqual({ selectionRef: 'expired', scope: 'user', cwd: '/project', initiatedFrom: 'conversation' });
expect(requests[2]!.parameters).not.toEqual(requests[0]!.parameters);
await expect(ctx.skillhub.installConversation({ selectionRef: 'expired' }, { ...context, cwd: '/other' }, signal)).rejects.toThrow();
await expect(ctx.skillhub.installConversation({ selectionRef: 'expired', scope: 'user' }, { ...context, cwd: '/other' }, signal)).rejects.toThrow();
await expect(ctx.skillhub.installConversation({ selectionRef: 'expired', path: '/other' }, context, signal)).rejects.toThrow();
expect(requests).toHaveLength(3);
} finally { await fiber.dispose(); await remove(); }
@ -58,10 +58,10 @@ test('new conversation install preserves fixed detail and never falls back from
Reflect.set(ctx.skillhub, 'prepareInstallation', async (...args: unknown[]) => { preparations.push(args); return {}; });
try {
const context = { sessionId: 'owner', cwd: '/project', callId: 'install-call' };
await ctx.skillhub.installConversation({ selectionRef: viewed }, context, signal);
await ctx.skillhub.installConversation({ selectionRef: viewed, scope: 'user' }, context, signal);
expect(preparations).toEqual([['owner', 'fixed-detail', 'user-target', 'conversation', signal]]);
await expect(ctx.skillhub.installConversation({ selectionRef: viewed, scope: 'project' }, context, signal)).rejects.toThrow();
await expect(ctx.skillhub.installConversation({ selectionRef: 'expired' }, context, signal)).rejects.toThrow();
await expect(ctx.skillhub.installConversation({ selectionRef: 'expired', scope: 'user' }, context, signal)).rejects.toThrow();
expect(preparations).toHaveLength(1);
} finally { await fiber.dispose(); await remove(); }
});
@ -141,8 +141,111 @@ test.each(['cwd', 'agent', 'source', 'abort'])('direct installation rejects %s d
});
Reflect.set(ctx.skillhub, 'installationTargetsFor', async () => { targets++; throw new Error('unexpected target resolution'); });
try {
await expect(ctx.skillhub.installConversation({ selectionRef: ref }, { sessionId: 'owner', cwd: '/project', callId: 'install' }, abort.signal)).rejects.toThrow();
await expect(ctx.skillhub.installConversation({ selectionRef: ref, scope: 'user' }, { sessionId: 'owner', cwd: '/project', callId: 'install' }, abort.signal)).rejects.toThrow();
expect(targets).toBe(0);
expect(() => selections.resolve(ref, scope)).toThrow();
} finally { await fiber.dispose(); await remove(); }
});
async function pendingFixture() {
const ctx = new Context();
const agent = { session: { id: 'owner', header: { cwd: '/project' } } };
let current = agent;
const remove = ctx.provide('agents', { get: () => current } as never);
const removeInvocation = ctx.provide('invocation', { request: { args: { agentId: 'owner' } } } as never);
const fiber = ctx.plugin(SkillHubService, { registryUrl: 'https://registry.example' });
await fiber;
const hostInstanceId = randomUUID();
let mutations = 0;
const runtime = { getStatus: () => ({ state: 'ready', hostInstanceId }),
submitInstall: async () => { mutations++; throw new Error('No transaction is allowed before UI confirmation'); } };
Reflect.set(ctx.skillhub, 'localRuntime', runtime);
Reflect.set(ctx.skillhub, 'prepareInstallation', async () => { mutations++; throw new Error('No preparation before UI confirmation'); });
Reflect.set(ctx.skillhub, 'installationTargetsFor', async () => { mutations++; throw new Error('No target before UI confirmation'); });
Reflect.set(ctx.skillhub, 'getSkillDetail', async () => { mutations++; throw new Error('No detail or fingerprint lock before UI confirmation'); });
const selections: ConversationSelections = Reflect.get(ctx.skillhub, 'selections');
const scope = { sessionId: 'owner', cwd: '/project', sourceRevision: ctx.skillhub.getContext().sourceRevision };
const ref = selections.add({ ...scope, callId: 'search' }, { namespace: 'team', slug: 'writer', version: '1.0.0' });
return { ctx, agent, scope, ref, selections, runtime, mutations: () => mutations,
replaceAgent: () => { current = { session: { ...agent.session } }; },
close: async () => { await fiber.dispose(); await removeInvocation(); await remove(); } };
}
const pendingContext = { sessionId: 'owner', cwd: '/project', callId: 'install-choice' };
test('search selection without scope returns an exact pending choice, preserves idempotency, and never prepares or submits', async () => {
const f = await pendingFixture();
const signal = new AbortController().signal;
try {
const pending = await f.ctx.skillhub.installConversation({ selectionRef: f.ref }, pendingContext, signal);
expect(pending).toMatchObject({ status: 'awaiting-confirmation', namespace: 'team', slug: 'writer', version: '1.0.0',
sourceRevision: f.scope.sourceRevision });
expect(pending && 'selectionRef' in pending ? pending.selectionRef : '').not.toBe(f.ref);
if (pending && 'selectionRef' in pending) {
await expect(f.ctx.skillhub.installConversation({ selectionRef: pending.selectionRef, scope: 'user' },
{ ...pendingContext, callId: 'bypass' }, signal)).rejects.toThrow('confirm in the installation card');
}
expect(pending).not.toHaveProperty('operationId');
expect(pending).not.toHaveProperty('fingerprint');
expect(pending).not.toHaveProperty('cwd');
expect(pending).not.toHaveProperty('detailToken');
expect(await f.ctx.skillhub.installConversation({ selectionRef: f.ref }, pendingContext, signal)).toEqual(pending);
await expect(f.ctx.skillhub.installConversation({ selectionRef: f.ref, scope: 'user' }, pendingContext, signal)).rejects.toThrow('parameters changed');
await expect(f.ctx.skillhub.installConversation({ namespace: 'other', slug: 'skill' }, pendingContext, signal)).rejects.toThrow('parameters changed');
expect(() => f.selections.resolve(f.ref, f.scope)).toThrow();
expect(f.mutations()).toBe(0);
} finally { await f.close(); }
});
test.each(['source', 'cwd', 'agent', 'runtime', 'clear', 'abort'])('pending choice cannot reopen installation authority after %s changes', async change => {
const f = await pendingFixture();
const abort = new AbortController();
try {
const pending = await f.ctx.skillhub.installConversation({ selectionRef: f.ref }, pendingContext, abort.signal);
if (change === 'source') Reflect.set(f.ctx.skillhub, 'sourceRevision', randomUUID());
if (change === 'cwd') f.agent.session.header.cwd = '/changed';
if (change === 'agent') f.replaceAgent();
if (change === 'runtime') Reflect.set(f.ctx.skillhub, 'localRuntime', undefined);
if (change === 'clear') f.selections.clear();
if (change === 'abort') abort.abort();
if (change !== 'abort' && pending && 'selectionRef' in pending) {
await expect(f.ctx.skillhub.openConversationDetail(pending.selectionRef, abort.signal)).rejects.toThrow();
}
// No retry with an old candidate is allowed to submit an installation.
await expect(f.ctx.skillhub.installConversation({ selectionRef: f.ref }, pendingContext, abort.signal)).rejects.toThrow();
expect(f.mutations()).toBe(0);
} finally { await f.close(); }
});
test('new explicit coordinates without scope resolve a candidate version once, not a prepared install snapshot', async () => {
const f = await pendingFixture();
let resolves = 0;
Reflect.set(f.ctx.skillhub, 'readSource', async (_revision: string, _signal: AbortSignal,
read: (adapter: { resolve: () => Promise<{ version: string }> }, signal: AbortSignal) => Promise<unknown>) =>
read({ resolve: async () => { resolves++; return { version: '2.1.0' }; } }, _signal));
try {
const request = { namespace: 'team', slug: 'writer' };
const first = await f.ctx.skillhub.installConversation(request, pendingContext, new AbortController().signal);
expect(first).toMatchObject({ status: 'awaiting-confirmation', namespace: 'team', slug: 'writer', version: '2.1.0' });
expect(first && 'selectionRef' in first ? f.selections.read(first.selectionRef, f.scope).version : null).toBe('2.1.0');
expect(await f.ctx.skillhub.installConversation(request, pendingContext, new AbortController().signal)).toEqual(first);
expect(resolves).toBe(1);
expect(f.mutations()).toBe(0);
} finally { await f.close(); }
});
test.each(['source', 'cwd', 'agent', 'runtime', 'abort'])('pending coordinate resolution fails closed on asynchronous %s drift', async change => {
const f = await pendingFixture();
const abort = new AbortController();
Reflect.set(f.ctx.skillhub, 'readSource', async () => {
if (change === 'source') Reflect.set(f.ctx.skillhub, 'sourceRevision', randomUUID());
if (change === 'cwd') f.agent.session.header.cwd = '/changed';
if (change === 'agent') f.replaceAgent();
if (change === 'runtime') Reflect.set(f.ctx.skillhub, 'localRuntime', undefined);
if (change === 'abort') abort.abort();
return { version: '2.1.0' };
});
try {
await expect(f.ctx.skillhub.installConversation({ namespace: 'team', slug: 'writer' }, pendingContext, abort.signal)).rejects.toThrow();
expect(f.mutations()).toBe(0);
} finally { await f.close(); }
});

View file

@ -3,8 +3,9 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { conversationInstallKey, conversationInstallRequest } from '../src/host/conversation-install-request.js';
test('installation accepts a reference or complete coordinates and defaults only omitted scope', () => {
expect(conversationInstallRequest.parse({ selectionRef: 'selection' })).toEqual({ selectionRef: 'selection', scope: 'user' });
test('installation accepts a reference or complete coordinates and preserves omitted scope for visible confirmation', () => {
expect(conversationInstallRequest.parse({ selectionRef: 'selection' })).toEqual({ selectionRef: 'selection' });
expect(conversationInstallRequest.parse({ selectionRef: 'selection', scope: 'user' })).toEqual({ selectionRef: 'selection', scope: 'user' });
expect(conversationInstallRequest.parse({ namespace: 'demo', slug: 'document-helper', scope: 'project' }))
.toEqual({ namespace: 'demo', slug: 'document-helper', scope: 'project' });
for (const value of [null, {}, { namespace: 'demo' }, { slug: 'skill' },

View file

@ -0,0 +1,28 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol';
import type { SkillDetail } from '../src/host/types.js';
import { DetailContent } from '../src/client/detail-content.js';
const detail: SkillDetail = {
detailToken: 'token', versionId: 1, namespace: 'team', slug: 'writer', version: '1.0.0', title: 'Writer', summary: 'Write documents',
tabs: { versions: 'unknown', reviews: 'unknown', securityAudit: 'unknown' },
documentation: { state: 'ready', content: '---\nname: private-frontmatter\ndescription: should-not-render\n---\n# 正文标题\n\n阅读正文。[unsafe](javascript:alert(1))\n\n<script>alert(1)</script>' },
};
const remote = {} as TypertRemoteNamespaceMap['skillhub'];
test('reading view hides pending capability banners and raw frontmatter while keeping sanitized body', () => {
const client = new QueryClient();
const html = renderToStaticMarkup(<QueryClientProvider client={client}><section className="skillhub-controls"><DetailContent
detail={detail} modes={{ versions: 'auto', reviews: 'auto', securityAudit: 'auto' }} remote={remote} retry={() => {}}
cacheIdentity="session/source/team/writer/1.0.0" /></section></QueryClientProvider>);
expect(html).not.toContain('重新检查可选内容');
expect(html).not.toContain('是否可读取尚未确认');
expect(html).not.toContain('private-frontmatter');
expect(html).not.toContain('should-not-render');
expect(html).toContain('<h1>正文标题</h1>');
expect(html).toContain('阅读正文。');
expect(html).not.toContain('<script>');
expect(html).not.toContain('href="javascript:');
expect(html).toContain('astron-sh-markdown');
});

View file

@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test';
import { stripMarkdownFrontmatter } from '../src/client/markdown-content.js';
import { RegistryAdapter } from '../src/host/registry-adapter.js';
function transport(handler: (url: URL) => Response | Promise<Response>): typeof fetch {
return Object.assign(async (input: string | URL | Request) => handler(new URL(String(input))), { preconnect: fetch.preconnect });
}
const version = (value: string, date: string | null, downloadAvailable = true) => ({ version: value, publishedAt: date, changelog: null, downloadAvailable });
describe('skill Markdown reader', () => {
test.each([
['---\nname: sample\ndescription: |\n long text\n---\n# Body', '# Body'],
['\uFEFF---\r\nname: sample\r\n---\r\n# Body', '# Body'],
['+++\nname = "sample"\n+++\nBody', 'Body'],
['---\nname: sample\n---', ''],
['---\nparagraph\n---\nBody', '---\nparagraph\n---\nBody'],
['---\nname: sample', '---\nname: sample'],
['Body\n---\nname: sample\n---', 'Body\n---\nname: sample\n---'],
['```yaml\n---\nname: sample\n---\n```', '```yaml\n---\nname: sample\n---\n```'],
])('strips only complete leading metadata fences', (input, expected) => {
expect(stripMarkdownFrontmatter(input)).toBe(expected);
});
});
describe('browsable version catalog', () => {
test('requests filtered server pagination once and preserves its exact global total/order', async () => {
let requests = 0;
const raw = [version('20261017.164831', '2026-10-17'), version('1.0.2', '2026-10-12')];
const client = new RegistryAdapter('https://registry.example', transport(url => {
requests++;
expect(url.pathname).toBe('/api/v1/skills/team/writer/versions');
expect(url.searchParams.get('downloadableOnly')).toBe('true');
expect(url.searchParams.get('page')).toBe('1');
expect(url.searchParams.get('size')).toBe('2');
return Response.json({ data: { items: raw, total: 25, page: 1, size: 2 } });
}));
expect(await client.browseVersions('team', 'writer', 1, 2)).toEqual({ items: raw, total: 25, page: 1, size: 2 });
expect(requests).toBe(1);
});
test('keeps existing capability query unchanged', async () => {
const client = new RegistryAdapter('https://registry.example', transport(url => {
expect(url.searchParams.has('downloadableOnly')).toBe(false);
return Response.json({ data: { items: [], total: 0, page: 0, size: 1 } });
}));
expect((await client.versions('team', 'writer', 0, 1)).total).toBe(0);
});
test.each([429, 503])('never converts transient %s to an empty version list', async status => {
const client = new RegistryAdapter('https://registry.example', transport(() => new Response(null, { status })));
await expect(client.browseVersions('team', 'writer', 0, 20)).rejects.toMatchObject({ status });
});
test('rejects mismatched pagination rather than inventing a filtered total', async () => {
const client = new RegistryAdapter('https://registry.example', transport(() => Response.json({ data: { items: [], total: 0, page: 2, size: 20 } })));
await expect(client.browseVersions('team', 'writer', 0, 20)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
});
});

View file

@ -0,0 +1,44 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { InstallControl, preferredInstallationScope, type InstallationServices } from '../src/client/install-control.js';
import { InstallationRequests } from '../src/client/installation-requests.js';
import type { InstalledSkillList, SkillDetail } from '../src/host/types.js';
const detail: SkillDetail = { namespace: 'global', slug: 'writer', title: '写作助手', summary: '', version: '1.2.0', detailToken: 'detail', versionId: 1, tabs: { versions: 'hidden', securityAudit: 'hidden', reviews: 'hidden' }, documentation: { state: 'ready', content: '# 写作助手' } };
const record: InstalledSkillList['items'][number] = { itemKey: 'item', namespace: 'global', slug: 'writer', version: '1.2.0', scope: 'project', state: 'present' };
const inventory = (items: InstalledSkillList['items']): InstalledSkillList => ({ items, complete: true, issues: [], unavailable: [] });
test('existing scope is preferred and another skill never changes the selection', () => {
expect(preferredInstallationScope(inventory([{ ...record, scope: 'user' }]), detail, ['project', 'user'])).toBe('user');
expect(preferredInstallationScope(inventory([record, { ...record, scope: 'user' }]), detail, ['project', 'user'])).toBe('project');
expect(preferredInstallationScope(inventory([{ ...record, slug: 'other', scope: 'user' }]), detail, ['project', 'user'])).toBe('project');
expect(preferredInstallationScope(inventory([]), detail, ['user'])).toBe('user');
// Installed scopes remain viewable even if they are not currently writable.
expect(preferredInstallationScope(inventory([{ ...record, scope: 'user' }]), detail, ['project'])).toBe('user');
});
function render(items: InstalledSkillList['items']) {
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } });
const requests = new InstallationRequests();
client.setQueryData(['skillhub-install-targets', 'session', 'detail'], { hostInstanceId: 'host', targets: [{ scope: 'project', targetToken: 'target' }, { scope: 'user', targetToken: 'user' }], unavailable: [] });
client.setQueryData(['skillhub-installed', 'session', 'source'], inventory(items));
client.setQueryData(['skillhub-compatibility'], { dependencyBaseline: 'matched', localWritesAllowed: true });
const unexpected = async () => { throw new Error('SSR must not call transport'); };
const services: InstallationServices = { sessionId: 'session', requests, targets: unexpected, installed: unexpected, start: unexpected, operation: unexpected, compatibility: unexpected, manageInstalled: () => {} };
try { return renderToStaticMarkup(<QueryClientProvider client={client}><InstallControl detail={detail} sourceRevision="source" services={services} initiatedFrom="plaza" /></QueryClientProvider>); }
finally { client.clear(); requests.dispose(); }
}
test('current inventory renders a removal action without a duplicate install action', () => {
const html = render([record]);
expect(html).toContain('已安装 v1.2.0'); expect(html).toContain('>卸载</button>');
expect(html).not.toContain('>安装到此项目</button>'); expect(html).not.toContain('安装完成');
expect(html).not.toContain('当前范围未安装'); expect(html).not.toContain('DSH Host 上该用户');
});
test('empty verified inventory renders an explicit scoped install action', () => {
const html = render([]);
expect(html).toContain('>安装到此项目</button>');
expect(html).not.toContain('安装完成'); expect(html).not.toContain('重新安装到');
expect(html).not.toContain('>卸载</button>');
});

View file

@ -1,7 +1,7 @@
import { expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { parseInstallResult } from '../src/client/install-result.js';
import type { InstallResult } from '../src/client/install-result.js';
import type { InstallResult, InstallFailure, PendingInstallResult } from '../src/client/install-result.js';
const result = { schemaVersion: 1, kind: 'skillhub-install', status: 'accepted', toolCallId: 'call',
operationId: randomUUID(), hostInstanceId: randomUUID(), sourceRevision: randomUUID(),
@ -19,3 +19,31 @@ test('installation history preserves accepted identity but never accepts instruc
expect(parseInstallResult(value)).toBeUndefined();
}
});
test('pending history is strictly disjoint from accepted and failed history', async () => {
const { parsePendingInstallResult, parseInstallFailure } = await import('../src/client/install-result.js');
const pending = { schemaVersion: 1, kind: 'skillhub-install', status: 'awaiting-confirmation', toolCallId: 'call',
namespace: 'team', slug: 'writer', version: '1.0.0', selectionRef: randomUUID(),
sourceLabel: 'https://registry.example', sourceRevision: randomUUID(), createdAt: '2026-10-20T00:00:00Z' } satisfies PendingInstallResult;
expect(parsePendingInstallResult(pending)).toEqual(pending);
expect(parseInstallResult(pending)).toBeUndefined();
expect(parseInstallFailure(pending)).toBeUndefined();
expect(parsePendingInstallResult(result)).toBeUndefined();
const failure = { schemaVersion: 1, kind: 'skillhub-install', status: 'not-confirmed', toolCallId: 'call', code: 'CONTEXT_STALE' } satisfies InstallFailure;
expect(parseInstallFailure(failure)).toEqual(failure);
expect(parsePendingInstallResult(failure)).toBeUndefined();
for (const extra of [{ operationId: randomUUID() }, { detailToken: 'secret' }, { targetLabel: 'user' },
{ path: '/private' }, { fingerprint: 'abc' }, { scope: 'user' }, { selectionRef: '../bad' },
{ sourceLabel: 'https://secret:token@example.com' }]) {
expect(parsePendingInstallResult({ ...pending, ...extra })).toBeUndefined();
}
});
test('cancelled history is strict and never claims an accepted operation', async () => {
const { isCancelledInstallResult } = await import('../src/client/install-result.js');
const value = { schemaVersion: 1, kind: 'skillhub-install', status: 'cancelled', toolCallId: 'cancel-call' };
expect(isCancelledInstallResult(value)).toBe(true);
expect(isCancelledInstallResult({ ...value, operationId: 'pretend-operation' })).toBe(false);
expect(parseInstallResult(value)).toBeUndefined();
});

View file

@ -53,7 +53,7 @@ test('saved acceptance uses original operation identity and presentation never i
return operation;
});
const tool = tools.get(INSTALL_TOOL_NAME)!;
const result = await tool.execute({ selectionRef: 'ref' }, context);
const result = await tool.execute({ selectionRef: 'ref', scope: 'user' }, context);
expect(result).toMatchObject({ status: 'accepted', operationId: operation.operationId, toolCallId: 'call',
sourceLabel: 'Original Registry', hostLabel: 'Original Host', createdAt: operation.context.createdAt });
expect(result).not.toHaveProperty('phase');
@ -76,3 +76,24 @@ test('installation failures do not expose Host filesystem diagnostics', async ()
expect(JSON.stringify(result)).not.toContain('/private');
expect(JSON.stringify(result)).not.toContain('secret');
});
test('omitted scope returns visible confirmation without a fake operation or leaking its choice to model text', async () => {
const tools = registry();
const pending = { schemaVersion: 1 as const, kind: 'skillhub-install' as const, status: 'awaiting-confirmation' as const,
toolCallId: 'call', selectionRef: randomUUID(), namespace: 'team', slug: 'writer', version: '1.0.0',
sourceLabel: 'https://registry.example', sourceRevision: randomUUID(), createdAt: new Date().toISOString() };
registerInstallTool(tools, async request => {
expect(request).toEqual({ selectionRef: 'ref' });
return pending;
});
const tool = tools.get(INSTALL_TOOL_NAME)!;
const result = await tool.execute({ selectionRef: 'ref' }, context);
expect(result).toEqual(pending);
expect(result).not.toHaveProperty('operationId');
const text = JSON.stringify(tool.output.render({}, pending));
expect(text).toContain('Not installed');
expect(text).toContain('choose current project or current user');
expect(text).toContain('Do not call this tool again');
expect(text).not.toContain(pending.selectionRef);
expect(tool.output.presentationMeta!({}, pending)).toEqual(pending);
});

View file

@ -0,0 +1,53 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import type { NativeOperation } from '../src/host/types.js';
import type { InstallationAttempt } from '../src/client/installation-requests.js';
import { installationProgressState, InstallationProgressSummary } from '../src/client/installation-progress.js';
import { shouldRefreshInstallation } from '../src/client/use-installation-operation.js';
const operation: NativeOperation = { operationId: 'operation', revision: 0, kind: 'install', namespace: 'team', slug: 'writer', version: '1',
targetLabel: 'Project', phase: 'accepted', files: 'pending', discovery: 'unconfirmed', recovery: 'not-needed', warningCount: 0,
context: { sourceIdentity: 'https://registry.example', sourceLabel: 'Registry', sourceRevision: 'source', hostLabel: 'Host',
hostInstanceId: 'host', initiatedFrom: 'conversation', createdAt: '2026-10-14T00:00:00Z', updatedAt: '2026-10-14T00:00:00Z' } };
const attempt: InstallationAttempt = { state: 'accepted', operation, request: { detailToken: 'detail', targetToken: 'target',
hostInstanceId: 'host', initiatedFrom: 'conversation', requestKey: 'key', mode: 'new' } };
test('progress follows actual phases and never fabricates intermediate percentages', () => {
for (const phase of ['accepted', 'staging', 'revalidating', 'committing', 'recovering'] as const) {
const value = { ...operation, phase };
expect(installationProgressState(attempt, value, false).state).toBe('running');
const html = renderToStaticMarkup(<InstallationProgressSummary attempt={attempt} operation={value} readFailed={false} />);
expect(html).toContain('role="progressbar"');
expect(html).not.toContain('aria-valuenow');
}
expect(installationProgressState({ ...attempt, state: 'submitting' }, undefined, false).label).toBe('正在提交安装');
expect(installationProgressState({ ...attempt, state: 'submitting', operation: null }, null, false).state).toBe('running');
});
test('compact completion keeps essential discovery warning without exposing technical metadata', () => {
const value = { ...operation, phase: 'completed' as const, discovery: 'confirmed' as const };
const html = renderToStaticMarkup(<InstallationProgressSummary attempt={attempt} operation={value} readFailed={false} />);
expect(html).toContain('安装完成');
expect(html).toContain('技能已就绪');
expect(html).toContain('aria-valuenow="100"');
for (const text of ['registry.example', '2026-10-14', '原操作', '刷新发现状态', '操作 ID']) expect(html).not.toContain(text);
expect(installationProgressState(attempt, { ...value, discovery: 'unconfirmed' }, false).message).toContain('尚待确认');
expect(installationProgressState(attempt, { ...value, warningCount: 1 }, false).label).toContain('告警');
});
test('errors and uncertain results stop animation instead of claiming success', () => {
for (const phase of ['failed', 'unknown', 'recovery-required'] as const) expect(installationProgressState(attempt, { ...operation, phase }, false).state).toBe('stopped');
expect(installationProgressState({ ...attempt, state: 'unresolved' }, undefined, false).state).toBe('stopped');
expect(installationProgressState(attempt, operation, true).state).toBe('stopped');
expect(installationProgressState(attempt, null, false).state).toBe('stopped');
});
test('only terminal phase transitions refresh inventory, repeated receipt reads do not', () => {
const completed = { ...operation, phase: 'completed' as const };
expect(shouldRefreshInstallation(undefined, completed)).toBe(true);
expect(shouldRefreshInstallation(operation, completed)).toBe(true);
expect(shouldRefreshInstallation(completed, { ...completed, revision: 2 })).toBe(false);
expect(shouldRefreshInstallation(completed, { ...operation, phase: 'recovery-required' })).toBe(true);
expect(shouldRefreshInstallation(operation, operation)).toBe(false);
expect(shouldRefreshInstallation(operation, null)).toBe(false);
});

View file

@ -21,7 +21,7 @@ describe('source-aware installation badges', () => {
{ scope: 'user', version: '1.0.0', state: 'present' },
{ scope: 'project', version: '2.0.0', state: 'present' },
] });
expect(html).toContain('v1.0.0 · 当前用户 / v2.0.0 · 当前项目');
expect(html).toContain('v1.0.0 · 所有项目 / v2.0.0 · 仅此项目');
expect(html).toContain('data-tone="success"');
expect(html).toContain('文件存在不代表 DSH 已启用或技能已执行');
expect(html).toContain('role="status"');
@ -41,15 +41,15 @@ describe('source-aware installation badges', () => {
expect(render({ ...snapshot, unavailable: ['project'] })).toContain('安装状态未知');
expect(render({ ...snapshot, complete: false })).toContain('安装状态未知');
const html = render({ ...snapshot, unavailable: ['project'], records: [{ scope: 'user', version: '1.0.0', state: 'present' }] });
expect(html).toContain('已安装 v1.0.0 · 当前用户 · 部分未知');
expect(html).toContain('已安装 v1.0.0 · 所有项目 · 部分未知');
});
test('mixed records retain the installed location and warn about the abnormal one', () => {
const html = render({ ...snapshot, records: [
{ scope: 'user', version: '1.0.0', state: 'present' },
{ scope: 'project', version: '2.0.0', state: 'mismatch' },
] });
expect(html).toContain('已安装 v1.0.0 · 当前用户 · 部分异常');
expect(html).toContain('当前项目 · v2.0.0 · 安装记录与本地文件不一致');
expect(html).toContain('已安装 v1.0.0 · 所有项目 · 部分异常');
expect(html).toContain('仅此项目 · v2.0.0 · 安装记录与本地文件不一致');
});
test('history explicitly labels its snapshot time and does not imply current state', () => {
const html = render(snapshot, { snapshot: true });

View file

@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SkillCard } from '../src/client/skill-card.js';
import { MarketCardActions } from '../src/client/market-card-actions.js';
import { InstallationStatus } from '../src/client/installation-status.js';
import { catalogLabel } from '../src/client/catalog-label.js';
const item = { namespace: 'demo', slug: 'document', displayName: '文档助手' };
describe('market presentation and action boundaries', () => {
test('whole-card details trigger and installation action are sibling buttons', () => {
const html = renderToStaticMarkup(<SkillCard item={item} version="1.0" onOpen={() => {}}
actions={<button>安装</button>} />);
expect(html).toContain('aria-label="查看 文档助手 @demo/document 1.0 详情"');
expect(html).not.toContain('详情<');
const detailStart = html.indexOf('class="astron-sh-card-open"');
const detailEnd = html.indexOf('</button>', detailStart);
expect(html.indexOf('>安装</button>')).toBeGreaterThan(detailEnd);
expect(html).not.toContain('astron-sh-card-open"><button');
});
test('unknown inventory disables direct install action', () => {
const client = new QueryClient();
const render = (verified: boolean) => renderToStaticMarkup(<QueryClientProvider client={client}>
<MarketCardActions records={[]} sourceRevision="r1" verified={verified} onInstall={() => {}} onManage={() => {}} />
</QueryClientProvider>);
expect(render(false)).toContain('disabled=""');
expect(render(true)).not.toContain('disabled=""');
});
test('hides only complete empty source records, preserving unsafe status', () => {
const base = { checkedAt: '2026-10-20T00:00:00Z', complete: true, unavailable: [], records: [] };
expect(renderToStaticMarkup(<InstallationStatus hideEmpty installation={base} />)).toBe('');
expect(renderToStaticMarkup(<InstallationStatus hideEmpty installation={{ ...base, complete: false }} />)).toContain('安装状态未知');
expect(renderToStaticMarkup(<InstallationStatus hideEmpty pending installation={base} />)).toContain('核对安装状态');
expect(renderToStaticMarkup(<InstallationStatus hideEmpty installation={{ ...base, records: [{ scope: 'user', version: '1', state: 'mismatch' }] }} />)).toContain('安装记录异常');
});
test('Chinese presentation does not change custom names or filter slugs', () => {
for (const [slug, displayName, expected] of [
['official', 'Official', '官方'], ['ai-competition', 'AI Competition', '人工智能竞赛'],
['aicomp-2026', 'aicomp-2026', '2026 人工智能竞赛'], ['coding', '开发工具', '开发工具'],
['private-label', 'Custom Workspace', 'Custom Workspace'], ['private-label', '', 'private-label'],
]) expect(catalogLabel({ slug: slug!, displayName: displayName! })).toBe(expected!);
});
});

View file

@ -0,0 +1,96 @@
import { expect, test } from 'bun:test';
import { Context } from '@deepseek-ai/cordis';
import { randomUUID } from 'node:crypto';
import SkillHubService from '@astron-team/skillhub-dsh';
import type { ConversationSelections } from '../src/host/conversation-selections.js';
import type { AskUserQuestionRequest, AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions';
async function fixture(answer: (request: AskUserQuestionRequest) => Promise<AskUserQuestionAnswer>) {
const ctx = new Context();
const agent = { session: { id: 'owner', header: { cwd: '/project' } } };
let current = agent;
const remove = ctx.provide('agents', { get: () => current } as never);
const removeInvocation = ctx.provide('invocation', { request: { args: { agentId: 'owner' } } } as never);
const removeQuestions = ctx.provide('userQuestions', { ask: answer } as never);
const fiber = ctx.plugin(SkillHubService, { registryUrl: 'https://registry.example' }); await fiber;
const submitted: unknown[] = [], prepared: unknown[] = [];
const runtime = { getStatus: () => ({ state: 'ready', hostInstanceId: randomUUID() }),
submitInstall: async (request: unknown, prepare: () => Promise<unknown>) => { submitted.push(request); prepared.push(await prepare()); return undefined; } };
const host = randomUUID(); runtime.getStatus = () => ({ state: 'ready', hostInstanceId: host });
Reflect.set(ctx.skillhub, 'localRuntime', runtime);
Reflect.set(ctx.skillhub, 'getSkillDetail', async () => ({ title: 'Writer', detailToken: 'detail', version: '1.0.0' }));
Reflect.set(ctx.skillhub, 'installationTargetsFor', async () => ({ targets: [{ scope: 'project', targetToken: 'project-target' }, { scope: 'user', targetToken: 'user-target' }] }));
Reflect.set(ctx.skillhub, 'prepareInstallation', async (...args: unknown[]) => args);
const scope = { sessionId: 'owner', cwd: '/project', sourceRevision: ctx.skillhub.getContext().sourceRevision };
const selections: ConversationSelections = Reflect.get(ctx.skillhub, 'selections');
const ref = selections.add({ ...scope, callId: 'search' }, { namespace: 'team', slug: 'writer', version: '1.0.0' });
const context = { sessionId: 'owner', cwd: '/project', callId: 'install' };
return { ctx, agent, ref, context, submitted, prepared, setAgent: () => { current = { session: { ...agent.session } }; },
close: async () => { Reflect.set(ctx.skillhub, 'localRuntime', undefined); await fiber.dispose(); removeQuestions(); removeInvocation(); remove(); } };
}
test('native input question waits for an exact human scope, then submits once for repeated calls', async () => {
let respond!: (value: AskUserQuestionAnswer) => void;
let seen!: AskUserQuestionRequest;
let observed!: () => void;
const questionShown = new Promise<void>(resolve => { observed = resolve; });
const answer = new Promise<AskUserQuestionAnswer>(resolve => { respond = resolve; });
const f = await fixture(async request => { seen = request; observed(); return answer; });
try {
const signal = new AbortController().signal;
const first = f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, signal);
await questionShown;
const second = f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, signal);
expect(seen.agent === (f.agent as unknown)).toBe(true); expect(seen.signal).toBe(signal);
expect(seen.questions[0]?.detail).toContain('1.0.0');
expect(seen.questions[0]?.options?.map(x => x.label)).toEqual(['安装到当前项目', '安装到当前用户', '取消安装']);
expect(f.submitted).toHaveLength(0); expect(f.prepared).toHaveLength(0);
respond({ answers: [{ id: 'skillhub-install-scope', selected: ['安装到当前项目'] }] });
await Promise.all([first, second]);
expect(f.submitted).toHaveLength(1); expect(f.prepared[0]).toEqual(['owner', 'detail', 'project-target', 'conversation', signal]);
} finally { await f.close(); }
});
test.each([
{ selected: ['取消安装'] }, { selected: [] }, { selected: ['unknown'] },
{ selected: ['安装到当前项目'], custom: '安装到当前用户' }, { selected: ['安装到当前项目', '安装到当前用户'] },
])('cancelled/skipped/custom/ambiguous question answers never install: %j', async choice => {
const f = await fixture(async () => ({ answers: [{ id: 'skillhub-install-scope', ...choice, selected: [...choice.selected] }] }));
try {
expect(await f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, new AbortController().signal)).toMatchObject({ status: 'cancelled' });
expect(f.submitted).toHaveLength(0); expect(f.prepared).toHaveLength(0);
} finally { await f.close(); }
});
test('missing native question UI preserves the pending confirmation fallback', async () => {
const f = await fixture(async () => { throw Object.assign(new Error('No answerer'), { code: 'NO_PROVIDER' }); });
try {
expect(await f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, new AbortController().signal)).toMatchObject({ status: 'awaiting-confirmation' });
expect(f.submitted).toHaveLength(0);
} finally { await f.close(); }
});
test('changed live Agent after the human wait rejects the selection without writing', async () => {
const change = () => f.setAgent();
const f = await fixture(async () => { change(); return { answers: [{ id: 'skillhub-install-scope', selected: ['安装到当前项目'] }] }; });
try {
await expect(f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, new AbortController().signal)).rejects.toThrow();
expect(f.submitted).toHaveLength(0);
} finally { await f.close(); }
});
test('closing the native question cancels without installing', async () => {
const f = await fixture(async () => { throw Object.assign(new Error('Closed'), { code: 'ASK_CANCELLED' }); });
try {
expect(await f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, new AbortController().signal)).toMatchObject({ status: 'cancelled' });
expect(f.submitted).toHaveLength(0);
} finally { await f.close(); }
});
test('source changes while choosing a scope reject the old answer', async () => {
const f = await fixture(async () => { Reflect.set(f.ctx.skillhub, 'sourceRevision', randomUUID()); return { answers: [{ id: 'skillhub-install-scope', selected: ['安装到当前用户'] }] }; });
try {
await expect(f.ctx.skillhub.installConversationWithChoice({ selectionRef: f.ref }, f.context, new AbortController().signal)).rejects.toThrow();
expect(f.submitted).toHaveLength(0);
} finally { await f.close(); }
});

View file

@ -54,8 +54,8 @@ describe('native M0 package', () => {
});
test('official generator publishes the declared endpoints with scoped writes and strict codec', () => {
expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'checkUpdate', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareUninstall', 'prepareUpgrade', 'recoverOperation', 'reopenConversationDetail', 'startInstall', 'testRegistryConnection']);
for (const method of ['openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'prepareUninstall', 'prepareUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) {
expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'checkUpdate', 'checkVersionUpdate', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'startInstall', 'testRegistryConnection']);
for (const method of ['openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) {
const scoped = TYPERT_REMOTE.descriptors.find(item => item.method === method)!;
expect(scoped.invocation).toMatchObject({ kind: 'context', context: 'agent', wire: 'agentId' });
}

View file

@ -0,0 +1,22 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { randomUUID } from 'node:crypto';
import { PendingInstallation } from '../src/client/install-tool-view.js';
import type { PendingInstallResult } from '../src/client/install-result.js';
const pending: PendingInstallResult = { schemaVersion: 1, kind: 'skillhub-install', status: 'awaiting-confirmation',
toolCallId: 'call', selectionRef: randomUUID(), namespace: 'team', slug: 'writer', version: '1.0.0',
sourceLabel: 'https://registry.example', sourceRevision: randomUUID(), createdAt: new Date().toISOString() };
test('pending installation renders scope meanings and cannot install from missing session UI services', () => {
const html = renderToStaticMarkup(<PendingInstallation result={pending} />);
expect(html).toContain('尚未安装');
expect(html).toContain('选择范围并安装');
expect(html).toContain('disabled');
expect(html).toContain('当前项目:供这个项目中的 DSH 会话使用');
expect(html).toContain('当前用户:可在其他项目中发现,读取仍受会话权限限制');
expect(html).toContain('重新搜索');
expect(html).not.toContain(pending.selectionRef);
expect(html).not.toContain('安装成功');
expect(html).not.toContain('<dialog');
});

View file

@ -3,6 +3,7 @@ import { realpath, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import SkillHubService from '@astron-team/skillhub-dsh';
import type { RegistryAdapter, ResolvedSkill } from '../src/host/registry-adapter.js';
import { UpgradePlans } from '../src/host/upgrade-plans.js';
import type { UpgradeBaseline } from '../src/host/generated/native-contract.js';
@ -16,6 +17,8 @@ test('prepare upgrade stores a bounded plan only after baseline and version chec
let baseline: UpgradeBaseline = { status: 'ready', inventoryItem: { ...identity, targets: [] },
targets: [{ scope: 'project', root, path: join(root, 'writer'), metadata: identity, files: { 'SKILL.md': 'hash' } }] };
let resolves = 0;
let forceLatest = false;
const requested: Array<string | undefined> = [];
let pending = 0;
const lifetime = new AbortController();
const plans = new UpgradePlans();
@ -27,8 +30,11 @@ test('prepare upgrade stores a bounded plan only after baseline and version chec
fs: { resolve: async (path: string) => path, processPath: (path: string) => path, processPathFromHostPath: (path: string) => path },
runtime: { getStatus: () => ({ pendingOperations: pending, hostInstanceId: '3e36f379-2d53-4d80-b72a-4fe3642c5181' }), inspectUpgrade: async () => baseline,
assessUpdate: async () => ({ state: 'upgradable', reason: 'newer-version' }) } }),
readSource: async () => { resolves++; return { namespace: 'demo', slug: 'writer', version: '2.0.0', versionId: 2, fingerprint: 'new', downloadUrl: '/download' }; },
readSource: async (_revision: string, signal: AbortSignal, read: (registry: Pick<RegistryAdapter, 'resolve'>, signal: AbortSignal) => Promise<ResolvedSkill>) => read({ resolve: async (_namespace, _slug, version) => {
resolves++; requested.push(version); return { namespace: 'demo', slug: 'writer', version: forceLatest ? '2.0.0' : version ?? '2.0.0', versionId: 2, fingerprint: 'new', downloadUrl: '/download' };
} }, signal),
};
Object.setPrototypeOf(context, SkillHubService.prototype);
const prepare = () => Reflect.apply(SkillHubService.prototype.prepareUpgrade, context, ['source', 'item', new AbortController().signal]);
const ready = await prepare();
expect(ready.status).toBe('ready');
@ -36,11 +42,18 @@ test('prepare upgrade stores a bounded plan only after baseline and version chec
expect(ready.targets).toEqual([{ scope: 'project', path: join(root, 'writer') }]);
expect(plans.read(ready.planId, scope).candidate.version).toBe('2.0.0');
expect(resolves).toBe(1);
const selected = await Reflect.apply(SkillHubService.prototype.prepareVersionUpgrade, context, ['source', 'item', '1.5.0', new AbortController().signal]);
if (!selected.planId) throw new Error('Expected selected-version plan');
expect(selected.candidateVersion).toBe('1.5.0');
expect(plans.read(selected.planId, scope).candidate.version).toBe('1.5.0');
expect(requested).toEqual([undefined, '1.5.0']);
forceLatest = true;
await expect(Reflect.apply(SkillHubService.prototype.prepareVersionUpgrade, context, ['source', 'item', '1.5.0', new AbortController().signal])).rejects.toThrow('Selected version changed');
baseline = { status: 'blocked', reason: 'shared-target' };
expect(await prepare()).toMatchObject({ status: 'blocked', reason: 'shared-target', planId: null });
expect(resolves).toBe(1);
expect(resolves).toBe(3);
pending = 1;
expect(await prepare()).toMatchObject({ status: 'blocked', reason: 'recovery-required', planId: null });
expect(resolves).toBe(1);
expect(resolves).toBe(3);
} finally { await rm(cwd, { recursive: true, force: true }); }
});

View file

@ -0,0 +1,22 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { RemovalConfirmationSummary } from '../src/client/removal-control.js';
const item = { namespace: 'global', slug: 'meeting-notes', scope: 'user' };
test('removal summary keeps identity, version, chosen scope and the scope-specific impact', () => {
const html = renderToStaticMarkup(<RemovalConfirmationSummary item={item} version="1.2.0" stale={false} />);
expect(html).toContain('@global/meeting-notes · v1.2.0');
expect(html).toContain('所有项目(当前用户)');
expect(html).toContain('项目内的独立安装保留');
for (const text of ['来源:', '目标主机', '确认有效至', '操作 ID', '/tmp/', 'http://', '正在运行的任务']) expect(html).not.toContain(text);
});
test('project removal does not imply removing the user-level or other project installation', () => {
const html = renderToStaticMarkup(<RemovalConfirmationSummary item={{ ...item, scope: 'project' }} version="1.2.0" stale={false} />);
expect(html).toContain('仅此项目');
expect(html).toContain('用户级及其他项目的安装保留');
expect(html).not.toContain('移除当前用户共享的技能');
});
test('missing targets are explicitly record cleanup without file deletion', () => {
const html = renderToStaticMarkup(<RemovalConfirmationSummary item={item} version="1.2.0" stale />);
expect(html).toContain('仅移除失效记录,不删除文件');
});

View file

@ -7,6 +7,7 @@ test('update check revalidates local identity and only resolves remote metadata'
let reads = 0;
let resolves = 0;
let assessments = 0;
let expectedVersion: string | undefined;
let onResolve = () => {};
let onAssess = () => {};
const lifetime = new AbortController();
@ -16,9 +17,9 @@ test('update check revalidates local identity and only resolves remote metadata'
async readSource(_revision: string, signal: AbortSignal, resolve: (registry: object, signal: AbortSignal) => unknown) {
return resolve({ async resolve(namespace: string, slug: string, version: unknown) {
resolves++;
expect([namespace, slug, version]).toEqual(['demo', 'writer', undefined]);
expect([namespace, slug, version]).toEqual(['demo', 'writer', expectedVersion]);
onResolve();
return { version: '1.1.0', fingerprint: 'candidate' };
return { version: expectedVersion ?? '1.1.0', fingerprint: 'candidate' };
} }, signal);
},
localRuntime: { async assessUpdate(local: typeof installed, candidate: { version: string; fingerprint: string }) {
@ -27,24 +28,28 @@ test('update check revalidates local identity and only resolves remote metadata'
return { state: 'upgradable', reason: 'newer-version', currentVersion: local.version, candidateVersion: candidate.version };
} },
};
Object.setPrototypeOf(context, SkillHubService.prototype);
const check = (signal = new AbortController().signal) =>
Reflect.apply(SkillHubService.prototype.checkUpdate, context, ['source', 'item', signal]);
expect(await check()).toMatchObject({ state: 'upgradable', currentVersion: '1.0.0', candidateVersion: '1.1.0' });
expect([reads, resolves, assessments]).toEqual([2, 1, 1]);
expectedVersion = '1.0.5';
expect(await Reflect.apply(SkillHubService.prototype.checkVersionUpdate, context, ['source', 'item', expectedVersion, new AbortController().signal])).toMatchObject({ state: 'upgradable', candidateVersion: '1.0.5' });
expectedVersion = undefined;
current.state = 'missing';
expect(await check()).toMatchObject({ state: 'blocked', reason: 'local-record-unverified' });
expect(resolves).toBe(1);
expect(resolves).toBe(2);
current = { ...installed };
onResolve = () => { current.fingerprint = 'concurrently-changed'; };
await expect(check()).rejects.toThrow('Installed record changed');
expect(assessments).toBe(1);
expect(assessments).toBe(2);
current = { ...installed };
onResolve = () => { throw new Error('offline'); };
expect(await check()).toMatchObject({ state: 'unavailable', candidateVersion: null });
expect(assessments).toBe(1);
expect(assessments).toBe(2);
onResolve = () => {};
const controller = new AbortController();

View file

@ -0,0 +1,23 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { UpgradeConfirmationSummary } from '../src/client/upgrade-control.js';
import { selectedVersionUpdateMessage } from '../src/client/install-control.js';
import type { UpgradePlanView } from '../src/host/types.js';
const item = { namespace: 'global', slug: 'meeting-notes', version: '1.0.0', scope: 'user' };
const plan: UpgradePlanView = { hostInstanceId: 'host', status: 'ready', reason: 'newer-version', currentVersion: '1.0.0', candidateVersion: '1.1.0', planId: 'plan', expiresAt: '2026-10-20T00:00:00Z', targets: [{ scope: 'user', path: '/private/user/skills' }, { scope: 'project', path: '/private/project/skills' }] };
test('confirmation exposes exact version transition and every affected scope without raw paths', () => {
const html = renderToStaticMarkup(<UpgradeConfirmationSummary item={item} plan={plan} targetVersion="1.1.0" />);
expect(html).toContain('v1.0.0'); expect(html).toContain('v1.1.0');
expect(html).toContain('所有项目(当前用户)、仅此项目');
expect(html).not.toContain('/private/'); expect(html).not.toContain('2026-10-20');
});
test('selected non-latest version remains explicit while preflight is loading', () => {
const html = renderToStaticMarkup(<UpgradeConfirmationSummary item={item} plan={undefined} targetVersion="1.1.0" />);
expect(html).toContain('v1.1.0'); expect(html).not.toContain('最新版');
});
test('older and unorderable versions explain that no upgrade can be submitted', () => {
const update = { state: 'blocked' as const, reason: 'remote-older', currentVersion: '1.2.0', candidateVersion: '1.0.0', checkedAt: '' };
expect(selectedVersionUpdateMessage(update)).toContain('不支持降级');
expect(selectedVersionUpdateMessage({ ...update, reason: 'unknown-order' })).toContain('无法可靠比较版本');
expect(selectedVersionUpdateMessage({ ...update, state: 'current' })).toContain('无需升级');
});

View file

@ -182,10 +182,13 @@ public class SkillController extends BaseApiController {
@PathVariable String slug,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "false") boolean downloadableOnly,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
Page<SkillVersion> versions = skillQueryService.listVersions(
Page<SkillVersion> versions = downloadableOnly ? skillQueryService.listVersions(
namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(),
PageRequest.of(page, size), true) : skillQueryService.listVersions(
namespace,
slug,
userId,

View file

@ -35,6 +35,7 @@ import java.util.TimeZone;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -378,6 +379,24 @@ class SkillControllerTest {
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
@Test
void downloadableVersionFilterShouldPreserveFilteredPagination() throws Exception {
SkillVersion version = new SkillVersion(1L, "2.0.0", "owner-1");
var pageable = org.springframework.data.domain.PageRequest.of(1, 1);
when(skillQueryService.listVersions("team", "demo", null, Map.of(), pageable, true))
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(version), pageable, 3));
when(skillQueryService.isDownloadAvailable(eq(version), org.mockito.ArgumentMatchers.nullable(String.class))).thenReturn(true);
mockMvc.perform(get("/api/v1/skills/team/demo/versions")
.param("downloadableOnly", "true").param("page", "1").param("size", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.total").value(3))
.andExpect(jsonPath("$.data.items[0].version").value("2.0.0"))
.andExpect(jsonPath("$.data.items[0].downloadAvailable").value(true));
verify(skillQueryService).listVersions("team", "demo", null, Map.of(), pageable, true);
}
@Test
void listVersionsShouldExposeDownloadAvailability() throws Exception {
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1");

View file

@ -548,11 +548,24 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
return listVersions(namespaceSlug, skillSlug, currentUserId, userNsRoles, pageable, false);
}
public Page<SkillVersion> listVersions(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable,
boolean downloadableOnly) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
Comparator<SkillVersion> newestFirst = Comparator
.comparing(SkillVersion::getPublishedAt, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(SkillVersion::getId, Comparator.reverseOrder());
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles) && !downloadableOnly) {
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|| version.getStatus() == SkillVersionStatus.PENDING_REVIEW
@ -572,10 +585,13 @@ public class SkillQueryService {
.toList();
} else {
visibleVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);
skill.getId(), SkillVersionStatus.PUBLISHED).stream()
.filter(version -> !downloadableOnly || isDownloadAvailable(version))
.sorted(newestFirst)
.toList();
}
// Manual pagination
// Filter and order the complete visible result before pagination.
int start = Math.min((int) pageable.getOffset(), visibleVersions.size());
int end = Math.min(start + pageable.getPageSize(), visibleVersions.size());
List<SkillVersion> pageContent = visibleVersions.subList(start, end);

View file

@ -1612,6 +1612,65 @@ class SkillQueryServiceTest {
result.getContent().stream().map(SkillVersion::getVersion).toList());
}
@Test
void downloadableVersionsShouldFilterBeforePaginationAndSortGlobally() throws Exception {
Namespace namespace = new Namespace("team", "Team", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, "demo", "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion oldest = downloadableVersion(10L, "1.0.0", "2026-01-01T00:00:00Z");
SkillVersion newest = downloadableVersion(11L, "2.0.0", "2026-10-01T00:00:00Z");
SkillVersion tied = downloadableVersion(12L, "2.0.1", "2026-10-01T00:00:00Z");
SkillVersion unavailable = downloadableVersion(13L, "3.0.0", "2026-10-10T00:00:00Z");
unavailable.setDownloadReady(false);
SkillVersion yanked = downloadableVersion(14L, "4.0.0", "2026-10-11T00:00:00Z");
yanked.setYankedAt(Instant.parse("2026-10-12T00:00:00Z"));
SkillVersion undated = downloadableVersion(15L, "0.1.0", null);
skill.setLatestVersionId(11L);
when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(skill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(oldest, unavailable, newest, yanked, undated, tied));
Page<SkillVersion> first = service.listVersions("team", "demo", null, Map.of(), PageRequest.of(0, 2), true);
Page<SkillVersion> second = service.listVersions("team", "demo", "owner-1", Map.of(), PageRequest.of(1, 2), true);
Page<SkillVersion> empty = service.listVersions("team", "demo", null, Map.of(), PageRequest.of(2, 2), true);
assertEquals(List.of("2.0.1", "2.0.0"), first.map(SkillVersion::getVersion).getContent());
assertEquals(List.of("1.0.0", "0.1.0"), second.map(SkillVersion::getVersion).getContent());
assertEquals(4, first.getTotalElements());
assertEquals(4, second.getTotalElements());
assertTrue(empty.isEmpty());
assertEquals(4, empty.getTotalElements());
verify(skillVersionRepository, never()).findBySkillId(1L);
verifyNoInteractions(objectStorageService);
}
@Test
void downloadableVersionsShouldPreservePrivateSkillAuthorization() throws Exception {
Namespace namespace = new Namespace("team", "Team", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, "demo", "owner-1", SkillVisibility.PRIVATE);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(skill));
assertThrows(DomainBadRequestException.class, () -> service.listVersions(
"team", "demo", null, Map.of(), PageRequest.of(0, 20), true));
verifyNoInteractions(skillVersionRepository, objectStorageService);
}
private SkillVersion downloadableVersion(Long id, String name, String publishedAt) throws Exception {
SkillVersion version = new SkillVersion(1L, name, "owner-1");
setId(version, id);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
version.setPublishedAt(publishedAt == null ? null : Instant.parse(publishedAt));
return version;
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

View file

@ -19789,6 +19789,7 @@ export interface operations {
query?: {
page?: number;
size?: number;
downloadableOnly?: boolean;
};
header?: never;
path: {
@ -19815,6 +19816,7 @@ export interface operations {
query?: {
page?: number;
size?: number;
downloadableOnly?: boolean;
};
header?: never;
path: {