dsh-next-actions 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/DESIGN.md +75 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +62 -2
- package/assets/card-preview-dark.svg +46 -0
- package/assets/card-preview.svg +46 -0
- package/client.js +520 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +14 -0
- package/index.js +147 -0
- package/lib/normalize.js +56 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 版本记录
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-11-11
|
|
4
|
+
|
|
5
|
+
首个公开发布。
|
|
6
|
+
|
|
7
|
+
- 回复下方显示 1–3 条下一步建议:一两句上下文,加至多 3 个建议按钮;推荐项置顶、带 ✦ 与品牌色描边。
|
|
8
|
+
- 点击只填入:草稿为空或仍是未改动的填入文字时整段替换;草稿里有内容时在光标处插入空行 + 指令,保留 `@` 引用,⌘Z 可撤销;永不自动发送。
|
|
9
|
+
- 「已使用」从会话日志推断:下一条用户消息里有整行原样发送 → ✓,改后发送 → 铅笔;判定按选项各自进行。
|
|
10
|
+
- 危险建议带 ⚠,同样只填入;风险来自模型字段或命令形状模式。
|
|
11
|
+
- 卡片随会话保存,刷新与回放后仍在;工具过程组里一行记录。
|
|
12
|
+
- 兼容 PTC(`run_code`)会话;子代理不注册工具;`enabled: false` 时不注册工具与提示词,零常驻 token。
|
package/DESIGN.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 设计:下一步建议
|
|
2
|
+
|
|
3
|
+
## 要解决的问题
|
|
4
|
+
|
|
5
|
+
一轮工作结束后,用户常常要自己想清楚下一句该说什么,再把它打出来。模型此时最了解刚发生了什么,
|
|
6
|
+
所以由它在收尾时顺手给出几步,用户点一下就能把完整指令放进输入框,改完自己发送。
|
|
7
|
+
|
|
8
|
+
整个产品服务一条路径:**看一眼 → 点一下 → 在输入框里改**。新增任何机制前,先确认它服务这条路径。
|
|
9
|
+
|
|
10
|
+
## 理念
|
|
11
|
+
|
|
12
|
+
1. **回答的下一句,而不是新模块。** 卡片紧跟回答,先用一两句话承接刚才的结果,再给出动作;
|
|
13
|
+
建议不会凭空出现,也不进入回答正文。
|
|
14
|
+
2. **交给人,不替人做。** 点击只填入,永不发送;输入框里已有的内容永不覆盖;危险操作同样只填入。
|
|
15
|
+
用户永远在发送前有最后一次修改的机会。
|
|
16
|
+
3. **会话日志是唯一事实。** 卡片来自工具调用的记录,"已使用"来自用户真正发出的下一条消息;
|
|
17
|
+
插件不另存状态,刷新、回放、换设备看到的都一样。"使用"指做了这一步,而不是按了这个按钮。
|
|
18
|
+
4. **安静,可以忽略。** 最多三条;状态不占文字,每个按钮至多一个前置图标,辅以描边和字色的轻微变化,
|
|
19
|
+
不加徽标、文字标签或弹层。不需要时继续对话即可,不弹窗、不设置、不打扰。
|
|
20
|
+
没有真实后续工作的回合不出卡片。
|
|
21
|
+
5. **长在 DSH 上。** 外观沿用官方动作卡与过程行,只用主题 token;用户不应看出这是另一套界面。
|
|
22
|
+
6. **少即是多。** 能由已有事实推出来的就不存,能由用户自己完成的就不做;
|
|
23
|
+
每一处代码都要能回答它服务哪条理念。
|
|
24
|
+
|
|
25
|
+
## 体验
|
|
26
|
+
|
|
27
|
+
**浅色主题**
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
**深色主题**
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
示意图采用各主题的 token 值;实际外观随 DSH 明暗主题变化。
|
|
36
|
+
|
|
37
|
+
- **上下文**:卡片第一行是 1–2 句话,说明刚才做了什么、为什么是这几步。
|
|
38
|
+
- **建议**:最多 3 条,按模型给出的顺序排列;悬停显示完整指令(原生提示),不改变布局。
|
|
39
|
+
- **推荐**:模型至多标 1 条,固定排第一;按钮描边带品牌蓝,前置 ✦。
|
|
40
|
+
- **危险**:模型标记或命令形状的风险模式命中时前置 ⚠,可访问名带「需确认」。
|
|
41
|
+
- **已使用**:这是从用户发出的文本推断出来的,不是点击记录;手动输入相近的内容也会被标记。
|
|
42
|
+
判定依据是这张卡片之后、第一个由用户消息开启的回合里的第一条消息:中间没有用户消息的回合(注入上下文、唤醒)跳过,
|
|
43
|
+
但如果那一轮自己出了卡片,这条消息就归新卡片;第一条消息没有文字(例如只回复图片)时不做判定,也不向后借。
|
|
44
|
+
对每个选项各自判定:
|
|
45
|
+
- 消息中有连续的整行与该指令一致(忽略空白)→ ✓。点击填入时指令总是成行出现,所以藏在一句话里的短词或「不要 + 指令」不算原样发送。
|
|
46
|
+
- 某一行或某一段按原顺序保留了指令的大部分字符,并且没有被大量新内容稀释(基于最长公共子序列的 Dice 相似度 ≥ 0.6)→ 铅笔,表示「改后发送」。
|
|
47
|
+
否定句同样算改后发送;去掉空白后不足 4 个字的指令只认原样发送。
|
|
48
|
+
- 其他情况不标。
|
|
49
|
+
|
|
50
|
+
已使用的按钮文字转为二级色。每个按钮只放一个前置图标:✓ 或铅笔 > ⚠ > ✦;所有状态都写进可访问名。
|
|
51
|
+
- **点击 = 填入**:草稿为空,或仍是卡片刚填入且未改动的文字 → 整段替换;草稿里有用户自己的内容 →
|
|
52
|
+
在光标处插入「空行 + 指令」,作为一次编辑器编辑,保留 `@` 引用,⌘Z 可撤销。
|
|
53
|
+
- **随会话保存**:每一轮的卡片都在,历史卡片仍可点击填入;只有回合刚结束时出现的卡片做展开动画。
|
|
54
|
+
- **过程行**:工具过程组里一行「下一步建议 · 正在生成 / 已生成 · N / 未显示」。
|
|
55
|
+
|
|
56
|
+
不做:自动发送、确认弹层、设置页、单轮忽略按钮、额外的模型请求。全局关闭用 Host 配置
|
|
57
|
+
`enabled: false`,此时不注册工具与提示词,零 token。
|
|
58
|
+
|
|
59
|
+
## 机制
|
|
60
|
+
|
|
61
|
+
| 部分 | 文件 | 职责 |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| Host | `index.js` | 为根 agent 注册 `suggest_next_actions` 与一段常驻提示;子代理不注册;`enabled:false` 时什么都不注册 |
|
|
64
|
+
| 规范化 | `lib/normalize.js` | 参数 → `{ shown, context, suggestions[≤3] }`:折叠空白、截断、按指令去重、推荐至多一条且置顶;危险标记来自模型字段或命令形状模式。结果作为 `tool/result.meta` 写入会话日志 |
|
|
65
|
+
| Client | `client.js`(手写单文件,含 CSS) | 两个 Conversation Definition:`next-actions` 把本工具结果上的 meta(native 与 PTC `run_code` 两条通路)发布为该轮卡片;`next-actions-sent` 把用户在该轮发出的第一条 `user/message` 发布为 `sent` 文本。`conversation.chat.turnTail` 渲染卡片;`tool.call.toolview` 渲染过程行 |
|
|
66
|
+
|
|
67
|
+
会话日志只追加,压缩不删除原 `tool/result`,所以客户端直接读 meta,不重算参数,也不需要构建步骤;
|
|
68
|
+
压缩产生的替换副本一律忽略,避免同一张卡片发布两次。第 N 轮的卡片通过 `useChat` 的 `timeline.turnOrder` / `timeline.turns` 读第 N+1 轮的 `sent` 文本来判定
|
|
69
|
+
"已使用"。
|
|
70
|
+
|
|
71
|
+
事件归到哪一轮由引擎决定。同一个 Definition 服务页面里所有会话的 assembler,翻页加载旧历史(`prepend`)时还会对更早的事件重跑 `match`,
|
|
72
|
+
所以 `match` 只看当前事件、不持有状态:卡片按调用 id(PTC 按根调用 id)、消息按消息 id 建 Context,
|
|
73
|
+
轮次在 `start` 里从引擎解析好的 `match.location` 读取。引擎规定每轮的同一个 Location key 只能有一个 owner,
|
|
74
|
+
因此同一轮里第一个产出卡片的调用拥有卡片,第一条用户消息拥有 `sent`;后来者通过 `reader.previous` 看到前驱已占用就不再发布。外部插件不能 require Harness Client 包,所以卡片样式按官方动作卡重写,类名用 `dshna-` 前缀;
|
|
75
|
+
刚结束的回合用 CSS `grid-template-rows` 展开卡片,避免回答被一帧顶起,`prefers-reduced-motion` 下不做动画。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LitiaEeloo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
dsh-next-actions
|
|
2
|
+
=================
|
|
3
|
+
|
|
4
|
+
Third-party notices for portions adapted from other works.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DeepSeek Harness official action card
|
|
8
|
+
-------------------------------------
|
|
9
|
+
|
|
10
|
+
The suggestion card and process row follow the DeepSeek Harness official
|
|
11
|
+
action card. Portions of client.js — the card and process-row CSS, and the
|
|
12
|
+
markup structure they style — are adapted from that work, with renamed
|
|
13
|
+
`dshna-` class names and DSH theme-token references.
|
|
14
|
+
|
|
15
|
+
Copyright (c) 2026 DeepSeek
|
|
16
|
+
Licensed under the MIT License, reproduced below.
|
|
17
|
+
|
|
18
|
+
MIT License
|
|
19
|
+
|
|
20
|
+
Copyright (c) 2026 DeepSeek
|
|
21
|
+
|
|
22
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
23
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
24
|
+
in the Software without restriction, including without limitation the rights
|
|
25
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
26
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
27
|
+
furnished to do so, subject to the following conditions:
|
|
28
|
+
|
|
29
|
+
The above copyright notice and this permission notice shall be included in all
|
|
30
|
+
copies or substantial portions of the Software.
|
|
31
|
+
|
|
32
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
33
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
34
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
35
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
36
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
37
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
38
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-next-actions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Suggested next actions under DeepSeek Harness replies — clicking one fills the input, never sends.
|
|
4
|
+
> DSH Web/Desktop 插件:在回复下方显示 1–3 条「下一步建议」,点击只填入输入框。
|
|
5
|
+
|
|
6
|
+
**状态:v0.1.0,已发布到 npm。** 设计与理念见 [`DESIGN.md`](DESIGN.md),版本记录见 [`CHANGELOG.md`](CHANGELOG.md)。
|
|
7
|
+
|
|
8
|
+
## 安装
|
|
9
|
+
|
|
10
|
+
要求 DSH 0.2.2-rc.1 或更高版本;Host 需要支持 Web Client(Desktop 与 Web 均可)。
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
dsh plugin --profile desktop add dsh-next-actions
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
也可以让 agent 用 `plugin_manager` 的 `install_bundle` 安装同名 bundle,或在 Web 侧栏的 Plugins 页面里选中它。装好后重启 DSH。
|
|
17
|
+
|
|
18
|
+
本地开发时用 `link:` 安装本目录:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
dsh plugin --profile desktop add link:/path/to/dsh-next-actions
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 功能
|
|
25
|
+
|
|
26
|
+
- 一两句上下文 + 最多 3 个建议按钮;卡片随会话保存,刷新与回放后仍在。
|
|
27
|
+
- 推荐项置顶并带 ✦ 与品牌色描边;下一条消息里有整行与某条建议一致 → 实勾,改后发送 → 铅笔(从会话日志里的文本推断,手动输入相近内容也会被标记)。
|
|
28
|
+
- 空草稿直接填入;已有草稿时在光标处插入(保留 `@` 引用,⌘Z 撤销);永不自动发送。
|
|
29
|
+
- 危险建议带 ⚠ 标记,同样只填入。
|
|
30
|
+
- 工具过程组里一行记录;子代理不注册工具;Host 配置 `enabled: false` 时零常驻 token。
|
|
31
|
+
- 兼容 PTC(`run_code`)会话。
|
|
32
|
+
|
|
33
|
+
## 结构
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
index.js Host:工具 + 根 agent 常驻提示 + enabled 门控
|
|
37
|
+
lib/normalize.js 参数规范化,结果写入 tool/result.meta
|
|
38
|
+
client.js Client(手写单文件):Definition、卡片、过程行与 CSS
|
|
39
|
+
cordis.patch.yml 插件行 patch
|
|
40
|
+
test/ node --test
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 开发
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
node --test # 测试
|
|
47
|
+
npm pack --dry-run # 打包检查
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
安装/卸载用 `plugin_manager` 的 `install_bundle` / `remove_bundle`;验证使用假 `DSH_HOME`。
|
|
51
|
+
Host 改动需重启 DSH,Client 改动需刷新页面。
|
|
52
|
+
|
|
53
|
+
关闭功能:在 profile 的 `cordis.patch.yml` 写
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
- id: dsh-next-actions
|
|
57
|
+
name: 'dsh-next-actions'
|
|
58
|
+
config: { enabled: false }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 许可
|
|
62
|
+
|
|
63
|
+
MIT(见 [LICENSE](LICENSE))。卡片外观参照 MIT 许可的 DSH 官方动作卡,归属说明见 [NOTICE](NOTICE)。非 DeepSeek 官方插件。
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="196" viewBox="0 0 720 196" role="img" aria-label="深色主题下的下一步建议卡片示意:一行上下文和三个按钮,分别带推荐、已使用、需确认标记">
|
|
2
|
+
<style>
|
|
3
|
+
text { font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", "Helvetica Neue", sans-serif; }
|
|
4
|
+
.muted { fill: #adb2b8; font-size: 12px; }
|
|
5
|
+
.reply { fill: #cfd3d6; font-size: 13px; }
|
|
6
|
+
.context { fill: #cfd3d6; font-size: 13px; }
|
|
7
|
+
.label { fill: #f9fafb; font-size: 13px; }
|
|
8
|
+
.label-used { fill: #cfd3d6; font-size: 13px; }
|
|
9
|
+
</style>
|
|
10
|
+
<rect x="0.5" y="0.5" width="719" height="195" rx="14" fill="#17181a" stroke="#ffffff0f" stroke-width="0.75"/>
|
|
11
|
+
<text class="reply" x="28" y="38">助手回答……</text>
|
|
12
|
+
<text class="muted" x="28" y="64">▸ 下一步建议 · 已生成 · 3</text>
|
|
13
|
+
<rect x="28" y="76" width="664" height="96" rx="12" fill="#232324" stroke="#ffffff1f" stroke-width="0.75"/>
|
|
14
|
+
<g transform="translate(48 92) scale(0.4444)">
|
|
15
|
+
<g transform="translate(-1.5 0.5)">
|
|
16
|
+
<rect x="5" y="7.6" width="21" height="5.4" rx="2.7" fill="#5E9DF5"/>
|
|
17
|
+
<rect x="5" y="15.3" width="16" height="5.4" rx="2.7" fill="#7AAEFF"/>
|
|
18
|
+
<rect x="5" y="23" width="11" height="5.4" rx="2.7" fill="#9CC3FF"/>
|
|
19
|
+
<path d="M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z" fill="#41E1AC"/>
|
|
20
|
+
</g>
|
|
21
|
+
</g>
|
|
22
|
+
<text class="context" x="70" y="104">修复已完成,改动集中在 src/api;目前只跑了类型检查。</text>
|
|
23
|
+
<g>
|
|
24
|
+
<rect x="48" y="118" width="118" height="32" rx="6" fill="#232324" stroke="#7aaaff" stroke-opacity="0.6" stroke-width="0.75"/>
|
|
25
|
+
<g transform="translate(60 128) scale(0.75)">
|
|
26
|
+
<path d="M8 2.5C8.4 5.6 10.4 7.6 13.5 8 10.4 8.4 8.4 10.4 8 13.5 7.6 10.4 5.6 8.4 2.5 8 5.6 7.6 7.6 5.6 8 2.5Z" fill="#7aaaff"/>
|
|
27
|
+
</g>
|
|
28
|
+
<text class="label" x="76" y="139">跑一下测试</text>
|
|
29
|
+
</g>
|
|
30
|
+
<g>
|
|
31
|
+
<rect x="174" y="118" width="118" height="32" rx="6" fill="#232324" stroke="#ffffff29" stroke-width="0.75"/>
|
|
32
|
+
<g transform="translate(186 128) scale(0.75)">
|
|
33
|
+
<path d="M3.5 8.5 6.5 11.5 12.5 4.5" fill="none" stroke="#6dd795" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
34
|
+
</g>
|
|
35
|
+
<text class="label-used" x="202" y="139">补回归用例</text>
|
|
36
|
+
</g>
|
|
37
|
+
<g>
|
|
38
|
+
<rect x="300" y="118" width="118" height="32" rx="6" fill="#232324" stroke="#ffffff29" stroke-width="0.75"/>
|
|
39
|
+
<g transform="translate(312 128) scale(0.75)">
|
|
40
|
+
<path d="M8 1.8 14.6 13.2H1.4L8 1.8Z" fill="none" stroke="#e7ae73" stroke-width="1.2" stroke-linejoin="round"/>
|
|
41
|
+
<path d="M8 6v3.4" stroke="#e7ae73" stroke-width="1.2" stroke-linecap="round"/>
|
|
42
|
+
<circle cx="8" cy="11.4" r="0.7" fill="#e7ae73"/>
|
|
43
|
+
</g>
|
|
44
|
+
<text class="label" x="328" y="139">推送到远端</text>
|
|
45
|
+
</g>
|
|
46
|
+
</svg>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="196" viewBox="0 0 720 196" role="img" aria-label="下一步建议卡片示意:一行上下文和三个按钮,分别带推荐、已使用、需确认标记">
|
|
2
|
+
<style>
|
|
3
|
+
text { font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", "Helvetica Neue", sans-serif; }
|
|
4
|
+
.muted { fill: #8f959e; font-size: 12px; }
|
|
5
|
+
.reply { fill: #5f6673; font-size: 13px; }
|
|
6
|
+
.context { fill: #5f6673; font-size: 13px; }
|
|
7
|
+
.label { fill: #1f2329; font-size: 13px; }
|
|
8
|
+
.label-used { fill: #5f6673; font-size: 13px; }
|
|
9
|
+
</style>
|
|
10
|
+
<rect x="0.5" y="0.5" width="719" height="195" rx="14" fill="#f6f7f9" stroke="#e4e6ea" stroke-width="0.75"/>
|
|
11
|
+
<text class="reply" x="28" y="38">助手回答……</text>
|
|
12
|
+
<text class="muted" x="28" y="64">▸ 下一步建议 · 已生成 · 3</text>
|
|
13
|
+
<rect x="28" y="76" width="664" height="96" rx="12" fill="#ffffff" stroke="#e0e2e6" stroke-width="0.75"/>
|
|
14
|
+
<g transform="translate(48 92) scale(0.4444)">
|
|
15
|
+
<g transform="translate(-1.5 0.5)">
|
|
16
|
+
<rect x="5" y="7.6" width="21" height="5.4" rx="2.7" fill="#5E9DF5"/>
|
|
17
|
+
<rect x="5" y="15.3" width="16" height="5.4" rx="2.7" fill="#7AAEFF"/>
|
|
18
|
+
<rect x="5" y="23" width="11" height="5.4" rx="2.7" fill="#9CC3FF"/>
|
|
19
|
+
<path d="M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z" fill="#41E1AC"/>
|
|
20
|
+
</g>
|
|
21
|
+
</g>
|
|
22
|
+
<text class="context" x="70" y="104">修复已完成,改动集中在 src/api;目前只跑了类型检查。</text>
|
|
23
|
+
<g>
|
|
24
|
+
<rect x="48" y="118" width="118" height="32" rx="6" fill="#ffffff" stroke="#7fa7ff" stroke-width="0.75"/>
|
|
25
|
+
<g transform="translate(60 128) scale(0.75)">
|
|
26
|
+
<path d="M8 2.5C8.4 5.6 10.4 7.6 13.5 8 10.4 8.4 8.4 10.4 8 13.5 7.6 10.4 5.6 8.4 2.5 8 5.6 7.6 7.6 5.6 8 2.5Z" fill="#3370ff"/>
|
|
27
|
+
</g>
|
|
28
|
+
<text class="label" x="76" y="139">跑一下测试</text>
|
|
29
|
+
</g>
|
|
30
|
+
<g>
|
|
31
|
+
<rect x="174" y="118" width="118" height="32" rx="6" fill="#ffffff" stroke="#d9dbe0" stroke-width="0.75"/>
|
|
32
|
+
<g transform="translate(186 128) scale(0.75)">
|
|
33
|
+
<path d="M3.5 8.5 6.5 11.5 12.5 4.5" fill="none" stroke="#2ea121" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
34
|
+
</g>
|
|
35
|
+
<text class="label-used" x="202" y="139">补回归用例</text>
|
|
36
|
+
</g>
|
|
37
|
+
<g>
|
|
38
|
+
<rect x="300" y="118" width="118" height="32" rx="6" fill="#ffffff" stroke="#d9dbe0" stroke-width="0.75"/>
|
|
39
|
+
<g transform="translate(312 128) scale(0.75)">
|
|
40
|
+
<path d="M8 1.8 14.6 13.2H1.4L8 1.8Z" fill="none" stroke="#ff8800" stroke-width="1.2" stroke-linejoin="round"/>
|
|
41
|
+
<path d="M8 6v3.4" stroke="#ff8800" stroke-width="1.2" stroke-linecap="round"/>
|
|
42
|
+
<circle cx="8" cy="11.4" r="0.7" fill="#ff8800"/>
|
|
43
|
+
</g>
|
|
44
|
+
<text class="label" x="328" y="139">推送到远端</text>
|
|
45
|
+
</g>
|
|
46
|
+
</svg>
|
package/client.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-next-actions.
|
|
3
|
+
*
|
|
4
|
+
* - Two Conversation Definitions publish turn data. `next-actions` holds the
|
|
5
|
+
* card from this tool's `tool/result.meta` (native) or `tool/ptc-dispatch`
|
|
6
|
+
* meta (PTC `run_code`); `next-actions-sent` holds the text of the turn's
|
|
7
|
+
* first human `user/message`. `match()` reads only the current event; the
|
|
8
|
+
* turn always comes from the Engine-resolved `match.location`, because one
|
|
9
|
+
* Definition serves every session's assembler and `prepend` re-matches
|
|
10
|
+
* older pages.
|
|
11
|
+
* - A `conversation.chat.turnTail` entry renders the card under every turn
|
|
12
|
+
* that produced one; clicking a suggestion fills the composer and never
|
|
13
|
+
* sends. A chip whose prompt the next user message sent as-is gets a check;
|
|
14
|
+
* one sent with edits gets a pencil. Both come from the session log.
|
|
15
|
+
* - A `tool.call.toolview` entry renders the one-line process row.
|
|
16
|
+
*
|
|
17
|
+
* External plugins must not require Harness Client packages, so the markup and
|
|
18
|
+
* CSS follow the official action card with renamed `dshna-` classes and theme
|
|
19
|
+
* tokens only. DSH serves this file as one classic script; it is hand-written
|
|
20
|
+
* source, not a build output.
|
|
21
|
+
*/
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: 'dsh-next-actions',
|
|
24
|
+
factory(require) {
|
|
25
|
+
const React = require('react')
|
|
26
|
+
const h = React.createElement
|
|
27
|
+
|
|
28
|
+
// Card and process-row CSS: values follow the official action card and
|
|
29
|
+
// propose_action row; only theme tokens are referenced.
|
|
30
|
+
const STYLES = `
|
|
31
|
+
.dshna-reveal {
|
|
32
|
+
display: grid;
|
|
33
|
+
grid-template-rows: 1fr;
|
|
34
|
+
margin: 16px 0;
|
|
35
|
+
}
|
|
36
|
+
.dshna-reveal > * { min-height: 0; }
|
|
37
|
+
.dshna-reveal[data-fresh] { animation: dshna-reveal .22s cubic-bezier(.4, 0, .2, 1) both; }
|
|
38
|
+
@keyframes dshna-reveal { from { grid-template-rows: 0fr; opacity: 0; } }
|
|
39
|
+
@media (prefers-reduced-motion: reduce) { .dshna-reveal[data-fresh] { animation: none; } }
|
|
40
|
+
.dshna-card {
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
padding: 14px 20px 16px;
|
|
43
|
+
border: .5px solid var(--dsw-alias-border-l2);
|
|
44
|
+
border-radius: var(--dsw-radius-lg);
|
|
45
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
46
|
+
color: var(--dsw-alias-label-primary);
|
|
47
|
+
font-family: var(--dsw-font-family);
|
|
48
|
+
}
|
|
49
|
+
.dshna-context {
|
|
50
|
+
margin: 0;
|
|
51
|
+
color: var(--dsw-alias-label-secondary);
|
|
52
|
+
font-size: 13px;
|
|
53
|
+
line-height: 1.6;
|
|
54
|
+
overflow-wrap: anywhere;
|
|
55
|
+
}
|
|
56
|
+
.dshna-mark { display: inline-block; vertical-align: -3px; margin-right: 6px; }
|
|
57
|
+
.dshna-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
|
58
|
+
.dshna-chip {
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 4px;
|
|
63
|
+
max-width: 100%;
|
|
64
|
+
height: 32px;
|
|
65
|
+
padding: 0 12px;
|
|
66
|
+
border: .5px solid var(--dsw-alias-border-l3);
|
|
67
|
+
border-radius: var(--dsw-radius-sm);
|
|
68
|
+
background: transparent;
|
|
69
|
+
color: var(--dsw-alias-label-primary);
|
|
70
|
+
font: inherit;
|
|
71
|
+
font-size: 13px;
|
|
72
|
+
line-height: 20px;
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
}
|
|
75
|
+
.dshna-chip:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
76
|
+
.dshna-chip:active:not(:disabled) { background: var(--dsw-alias-interactive-bg-active); }
|
|
77
|
+
.dshna-chip:disabled { cursor: not-allowed; opacity: .4; }
|
|
78
|
+
.dshna-chip:focus-visible {
|
|
79
|
+
outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
80
|
+
outline-offset: 2px;
|
|
81
|
+
}
|
|
82
|
+
.dshna-chip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
83
|
+
/* Recommended: tinted border + spark. Used (next message): check or pencil in success color; quieter label. */
|
|
84
|
+
.dshna-chip[data-recommended] { border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 60%, transparent); }
|
|
85
|
+
.dshna-spark { flex: none; color: var(--dsw-alias-state-business-primary); }
|
|
86
|
+
.dshna-chip[data-used] { color: var(--dsw-alias-label-secondary); }
|
|
87
|
+
.dshna-used { flex: none; color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 65%, var(--dsw-alias-label-primary)); }
|
|
88
|
+
.dshna-warn { flex: none; color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 65%, var(--dsw-alias-label-primary)); }
|
|
89
|
+
.dshna-row {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
gap: 6px;
|
|
93
|
+
min-width: 0;
|
|
94
|
+
height: calc(24px + var(--dsh-content-font-delta, 0px));
|
|
95
|
+
color: var(--dsw-alias-label-tertiary);
|
|
96
|
+
}
|
|
97
|
+
.dshna-row > svg { flex: none; }
|
|
98
|
+
.dshna-row-title { flex: none; font-size: var(--dsh-content-font-size-secondary, 13px); }
|
|
99
|
+
.dshna-row-summary { min-width: 0; margin-left: 2px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
const PKG = 'dsh-next-actions'
|
|
103
|
+
const NS = 'nextActions'
|
|
104
|
+
const TOOL_NAME = 'suggest_next_actions'
|
|
105
|
+
const KEY = 'next-actions'
|
|
106
|
+
const SENT_KEY = 'next-actions-sent'
|
|
107
|
+
|
|
108
|
+
const EN = Object.freeze({
|
|
109
|
+
'card.title': 'Suggested next actions',
|
|
110
|
+
'chip.recommended': 'Recommended',
|
|
111
|
+
'chip.danger': 'Needs confirmation',
|
|
112
|
+
'chip.sent': 'Sent',
|
|
113
|
+
'chip.edited': 'Sent with edits',
|
|
114
|
+
'chip.aria': '{label}: {prompt}',
|
|
115
|
+
'chip.ariaMarked': '{label} ({marks}): {prompt}',
|
|
116
|
+
'chip.markSeparator': ', ',
|
|
117
|
+
'row.title': 'Next actions',
|
|
118
|
+
'row.running': 'Writing',
|
|
119
|
+
'row.ok': 'Shown',
|
|
120
|
+
'row.error': 'Not shown',
|
|
121
|
+
})
|
|
122
|
+
const ZH = Object.freeze({
|
|
123
|
+
'card.title': '下一步建议',
|
|
124
|
+
'chip.recommended': '推荐',
|
|
125
|
+
'chip.danger': '需确认',
|
|
126
|
+
'chip.sent': '已发送',
|
|
127
|
+
'chip.edited': '改后发送',
|
|
128
|
+
'chip.aria': '{label}:{prompt}',
|
|
129
|
+
'chip.ariaMarked': '{label}({marks}):{prompt}',
|
|
130
|
+
'chip.markSeparator': ',',
|
|
131
|
+
'row.title': '下一步建议',
|
|
132
|
+
'row.running': '正在生成',
|
|
133
|
+
'row.ok': '已生成',
|
|
134
|
+
'row.error': '未显示',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// -----------------------------------------------------------------------
|
|
138
|
+
// Data: fold this tool's events into turn data
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The card model is the Host-normalized `meta` on the original tool result.
|
|
143
|
+
* Compaction retains the original event; replacement copies are ignored.
|
|
144
|
+
* Older metas may carry up to six suggestions.
|
|
145
|
+
*/
|
|
146
|
+
function modelOf(meta) {
|
|
147
|
+
if (meta?.shown !== true || typeof meta.context !== 'string' || !Array.isArray(meta.suggestions)) return null
|
|
148
|
+
const suggestions = meta.suggestions
|
|
149
|
+
.filter((suggestion) => typeof suggestion?.label === 'string' && typeof suggestion.prompt === 'string')
|
|
150
|
+
.slice(0, 3)
|
|
151
|
+
return suggestions.length === 0 ? null : { context: meta.context, suggestions }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isTurn = (turn) => Number.isSafeInteger(turn) && turn >= 0
|
|
155
|
+
const isId = (value) => (typeof value === 'string' && value !== '') || Number.isSafeInteger(value)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Turn of a Match as the Engine resolved it for this session's window. Only
|
|
159
|
+
* the Engine knows which session and position an event has, so `match()`
|
|
160
|
+
* never guesses a turn from earlier events.
|
|
161
|
+
*/
|
|
162
|
+
function turnOf(match) {
|
|
163
|
+
const location = match.location
|
|
164
|
+
const turn = location?.kind === 'turn' || location?.kind === 'step' ? location.turn?.turn : undefined
|
|
165
|
+
return isTurn(turn) ? turn : isTurn(match.event?.data?.turn) ? match.event.data.turn : null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A turn's Location key has one owner. Contexts of one kind form a chain by
|
|
170
|
+
* start order; `taken` says an earlier Context of the same turn already
|
|
171
|
+
* owns the key. The Engine replays a Context when its predecessor changes.
|
|
172
|
+
*/
|
|
173
|
+
function takenBefore(reader, kind, turn, owns) {
|
|
174
|
+
const previous = reader.previous(kind)?.state
|
|
175
|
+
return previous !== undefined && previous.turn === turn && (previous.taken || owns(previous))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Plain text of a message's content blocks. */
|
|
179
|
+
function textOf(content) {
|
|
180
|
+
if (typeof content === 'string') return content
|
|
181
|
+
if (!Array.isArray(content)) return ''
|
|
182
|
+
return content.map((block) => (block?.type === 'text' && typeof block.text === 'string' ? block.text : '')).join('')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Card: one Context per call, keyed like the official tool Definition.
|
|
187
|
+
* Native calls by `callId`; PTC (`run_code`) dispatches by their root call.
|
|
188
|
+
* The first call of a turn that produced a card owns the turn's card.
|
|
189
|
+
*/
|
|
190
|
+
const hasCard = (state) => state.model !== null
|
|
191
|
+
const cardDefinition = {
|
|
192
|
+
kind: KEY,
|
|
193
|
+
match(event) {
|
|
194
|
+
const data = event.data
|
|
195
|
+
switch (event.type) {
|
|
196
|
+
case 'tool/call':
|
|
197
|
+
return data.name === TOOL_NAME && isId(data.callId) ? { id: String(data.callId), role: 'start' } : null
|
|
198
|
+
case 'tool/result': {
|
|
199
|
+
// Replacement copies of a result must not re-publish the card.
|
|
200
|
+
const callId = data.message?.source?.callId ?? data.message?.toolCallId
|
|
201
|
+
return event.surfaceOp === 'append' && isId(callId) ? { id: String(callId), role: 'update' } : null
|
|
202
|
+
}
|
|
203
|
+
case 'tool/ptc-dispatch-start':
|
|
204
|
+
case 'tool/ptc-dispatch':
|
|
205
|
+
return data.name === TOOL_NAME && isId(data.rootCallId)
|
|
206
|
+
? { id: `ptc:${data.rootCallId}`, role: event.type === 'tool/ptc-dispatch-start' ? 'start' : 'update' }
|
|
207
|
+
: null
|
|
208
|
+
default:
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
start(_context, match, reader) {
|
|
213
|
+
const turn = turnOf(match)
|
|
214
|
+
const state = { turn, model: null, taken: turn !== null && takenBefore(reader, KEY, turn, hasCard) }
|
|
215
|
+
return cardDefinition.update({ state }, match)
|
|
216
|
+
},
|
|
217
|
+
update({ state }, { event }) {
|
|
218
|
+
const data = event.data
|
|
219
|
+
let model = null
|
|
220
|
+
if (event.type === 'tool/result' && event.surfaceOp === 'append') {
|
|
221
|
+
if (data.message === undefined || data.message.isError === true) return state
|
|
222
|
+
model = modelOf(data.meta)
|
|
223
|
+
} else if (event.type === 'tool/ptc-dispatch' && data.isError !== true) {
|
|
224
|
+
model = modelOf(data.meta)
|
|
225
|
+
}
|
|
226
|
+
return model === null ? state : { ...state, model }
|
|
227
|
+
},
|
|
228
|
+
buildLocationData({ state }, scope, previous) {
|
|
229
|
+
if (scope !== 'turn' || state === undefined || state.turn === null || state.model === null || state.taken) return null
|
|
230
|
+
if (previous?.key === KEY && previous.turn === state.turn && previous.value.model === state.model) return previous
|
|
231
|
+
return { kind: 'turn', turn: state.turn, key: KEY, value: { model: state.model } }
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Sent text: one Context per human message. The first message of a turn owns
|
|
237
|
+
* the turn's `sent`, text or not: a textless reply publishes an empty value
|
|
238
|
+
* so the card before it is not judged by a later message.
|
|
239
|
+
*/
|
|
240
|
+
const hasSent = () => true
|
|
241
|
+
const sentDefinition = {
|
|
242
|
+
kind: SENT_KEY,
|
|
243
|
+
match(event) {
|
|
244
|
+
const data = event.data
|
|
245
|
+
return event.type === 'user/message' && event.surfaceOp === 'append' && data.source?.kind === 'user' && isId(data.id)
|
|
246
|
+
? { id: String(data.id), role: 'start' }
|
|
247
|
+
: null
|
|
248
|
+
},
|
|
249
|
+
start(_context, match, reader) {
|
|
250
|
+
const turn = turnOf(match)
|
|
251
|
+
return { turn, text: textOf(match.event.data.content), taken: turn !== null && takenBefore(reader, SENT_KEY, turn, hasSent) }
|
|
252
|
+
},
|
|
253
|
+
update({ state }) {
|
|
254
|
+
return state
|
|
255
|
+
},
|
|
256
|
+
buildLocationData({ state }, scope, previous) {
|
|
257
|
+
if (scope !== 'turn' || state === undefined || state.turn === null || state.taken) return null
|
|
258
|
+
if (previous?.key === SENT_KEY && previous.turn === state.turn && previous.value.text === state.text) return previous
|
|
259
|
+
return { kind: 'turn', turn: state.turn, key: SENT_KEY, value: { text: state.text } }
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// -----------------------------------------------------------------------
|
|
264
|
+
// Card
|
|
265
|
+
// -----------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** Empty drafts and drafts the card wrote itself are replaced; anything else gets the action added. */
|
|
268
|
+
function fillMode(draft, owned) {
|
|
269
|
+
return draft.trim() === '' || draft === owned ? 'replace' : 'insert'
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let sharedCtx = null
|
|
273
|
+
|
|
274
|
+
function fill(actions, draft, owned, prompt, sessionId) {
|
|
275
|
+
let next = prompt
|
|
276
|
+
if (fillMode(draft, owned) === 'insert') {
|
|
277
|
+
next = null
|
|
278
|
+
try {
|
|
279
|
+
// One editor edit at the caret: keeps @ references, undoable with ⌘Z.
|
|
280
|
+
if (actions.insertText(`\n\n${prompt}`, actions.captureInsertion()) !== true) next = `${draft.trimEnd()}\n\n${prompt}`
|
|
281
|
+
} catch {
|
|
282
|
+
next = `${draft.trimEnd()}\n\n${prompt}`
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (next !== null) actions.setDraft(next)
|
|
286
|
+
try {
|
|
287
|
+
sharedCtx?.get?.('conversation')?.input?.shell?.(sessionId)?.focus?.()
|
|
288
|
+
} catch {
|
|
289
|
+
// Focus is a convenience; the draft is already filled.
|
|
290
|
+
}
|
|
291
|
+
return next === prompt ? prompt : null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function Mark() {
|
|
295
|
+
return h('svg', { width: 16, height: 16, viewBox: '0 0 36 36', fill: 'none', className: 'dshna-mark', 'aria-hidden': 'true' },
|
|
296
|
+
h('g', { transform: 'translate(-1.5 0.5)' },
|
|
297
|
+
h('rect', { x: 5, y: 7.6, width: 21, height: 5.4, rx: 2.7, fill: '#5E9DF5' }),
|
|
298
|
+
h('rect', { x: 5, y: 15.3, width: 16, height: 5.4, rx: 2.7, fill: '#7AAEFF' }),
|
|
299
|
+
h('rect', { x: 5, y: 23, width: 11, height: 5.4, rx: 2.7, fill: '#9CC3FF' }),
|
|
300
|
+
h('path', { d: 'M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z', fill: '#41E1AC' })))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function WarnIcon() {
|
|
304
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className: 'dshna-warn', 'aria-hidden': 'true' },
|
|
305
|
+
h('path', { d: 'M8 1.8 14.6 13.2H1.4L8 1.8Z', stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round' }),
|
|
306
|
+
h('path', { d: 'M8 6v3.4', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
|
|
307
|
+
h('circle', { cx: 8, cy: 11.4, r: 0.7, fill: 'currentColor' }))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function SparkIcon() {
|
|
311
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', className: 'dshna-spark', 'aria-hidden': 'true' },
|
|
312
|
+
h('path', { d: 'M8 2.5C8.4 5.6 10.4 7.6 13.5 8 10.4 8.4 8.4 10.4 8 13.5 7.6 10.4 5.6 8.4 2.5 8 5.6 7.6 7.6 5.6 8 2.5Z', fill: 'currentColor' }))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** "Sent as-is". */
|
|
316
|
+
function CheckIcon() {
|
|
317
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className: 'dshna-used', 'aria-hidden': 'true' },
|
|
318
|
+
h('path', { d: 'M3.5 8.5 6.5 11.5 12.5 4.5', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** "Sent with edits". */
|
|
322
|
+
function EditedIcon() {
|
|
323
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className: 'dshna-used', 'aria-hidden': 'true' },
|
|
324
|
+
h('path', { d: 'M10.5 3 13 5.5 6 12.5 3 13l.5-3L10.5 3Z', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// -----------------------------------------------------------------------
|
|
328
|
+
// Usage: which suggestions the next human message used
|
|
329
|
+
//
|
|
330
|
+
// An inference from the sent text, not a click record: typing similar text
|
|
331
|
+
// by hand is marked too. Clicking fills the prompt as whole lines (the
|
|
332
|
+
// empty draft replaced, or "\n\n" + prompt inserted), so "sent as-is"
|
|
333
|
+
// means whole lines equal to the prompt.
|
|
334
|
+
// -----------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Prompts shorter than this (whitespace removed) only count when sent as-is. */
|
|
337
|
+
const EDITED_MIN_LENGTH = 4
|
|
338
|
+
/** Order-aware similarity (Dice over the longest common subsequence) that counts as "sent with edits". */
|
|
339
|
+
const EDITED_SIMILARITY = 0.6
|
|
340
|
+
|
|
341
|
+
const squash = (value) => value.replace(/\s+/g, '').toLowerCase()
|
|
342
|
+
|
|
343
|
+
/** Characters of `left` kept in order in `right`. */
|
|
344
|
+
function commonLength(left, right) {
|
|
345
|
+
let row = new Array(right.length + 1).fill(0)
|
|
346
|
+
for (const character of left) {
|
|
347
|
+
const next = [0]
|
|
348
|
+
for (let index = 0; index < right.length; index += 1) {
|
|
349
|
+
next.push(character === right[index] ? row[index] + 1 : Math.max(row[index + 1], next[index]))
|
|
350
|
+
}
|
|
351
|
+
row = next
|
|
352
|
+
}
|
|
353
|
+
return row[right.length]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function similarity(target, text) {
|
|
357
|
+
// Dice cannot reach the threshold when lengths differ too much; skip the quadratic work.
|
|
358
|
+
if ((2 * Math.min(target.length, text.length)) / (target.length + text.length) < EDITED_SIMILARITY) return 0
|
|
359
|
+
return (2 * commonLength(target, text)) / (target.length + text.length)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 'full' when whole consecutive lines of the sent text equal the prompt;
|
|
364
|
+
* 'partial' when one line or paragraph keeps most of the prompt in order.
|
|
365
|
+
*/
|
|
366
|
+
function usageOf(prompt, sent) {
|
|
367
|
+
const target = Array.from(squash(prompt))
|
|
368
|
+
if (target.length === 0) return null
|
|
369
|
+
const lines = sent.split('\n').map(squash).filter((line) => line !== '')
|
|
370
|
+
const joined = target.join('')
|
|
371
|
+
for (let first = 0; first < lines.length; first += 1) {
|
|
372
|
+
let run = ''
|
|
373
|
+
for (let last = first; last < lines.length && run.length < joined.length; last += 1) {
|
|
374
|
+
run += lines[last]
|
|
375
|
+
if (run === joined) return 'full'
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (target.length < EDITED_MIN_LENGTH) return null
|
|
379
|
+
const paragraphs = sent.split(/\n\s*\n/).map(squash).filter((paragraph) => paragraph !== '')
|
|
380
|
+
for (const candidate of new Set([...lines, ...paragraphs])) {
|
|
381
|
+
if (similarity(target, Array.from(candidate)) >= EDITED_SIMILARITY) return 'partial'
|
|
382
|
+
}
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Text the user answered the card of `turn` with: the first message of the
|
|
388
|
+
* next turn opened by a human. Turns without a human message (injected
|
|
389
|
+
* context, wake-ups) are skipped, and a later turn showing its own card
|
|
390
|
+
* claims the next message instead. A human turn whose message has no text
|
|
391
|
+
* (an image-only reply) ends the search, so no later message is borrowed.
|
|
392
|
+
*/
|
|
393
|
+
function nextSent(snapshot, turn) {
|
|
394
|
+
const order = snapshot?.timeline?.turnOrder
|
|
395
|
+
const index = Array.isArray(order) ? order.indexOf(turn) : -1
|
|
396
|
+
if (index < 0) return ''
|
|
397
|
+
for (const later of order.slice(index + 1)) {
|
|
398
|
+
const data = snapshot.timeline.turns?.get(later)?.data
|
|
399
|
+
const sent = data?.get(SENT_KEY)
|
|
400
|
+
if (sent !== undefined) return typeof sent.text === 'string' ? sent.text : ''
|
|
401
|
+
if (data?.get(KEY)?.model) return ''
|
|
402
|
+
}
|
|
403
|
+
return ''
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Only a card mounted right after its turn ends animates in; history mounts instantly. */
|
|
407
|
+
function isFresh(turn) {
|
|
408
|
+
const time = turn?.end?.time
|
|
409
|
+
const value = typeof time === 'number' ? time : Date.parse(time)
|
|
410
|
+
return Number.isFinite(value) && Date.now() - value < 5000
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function NextActionsCard({ turn, inputActions, useInput, useChat, useSession, sessionId, t }) {
|
|
414
|
+
const ownedRef = React.useRef(null)
|
|
415
|
+
const draft = useInput?.((state) => state?.draft ?? '') ?? ''
|
|
416
|
+
const sent = useChat?.((snapshot) => nextSent(snapshot, turn?.turn)) ?? ''
|
|
417
|
+
const subagent = useSession?.((state) => state?.subagent ?? null)
|
|
418
|
+
|
|
419
|
+
const model = turn?.end === undefined ? undefined : turn.data?.get(KEY)?.model
|
|
420
|
+
const closing = turn?.data?.get('turn-tail')?.closing
|
|
421
|
+
const suggestions = closing && !subagent ? model?.suggestions : undefined
|
|
422
|
+
const usages = React.useMemo(
|
|
423
|
+
() => suggestions?.map(({ prompt }) => usageOf(prompt, sent)),
|
|
424
|
+
[suggestions, sent],
|
|
425
|
+
)
|
|
426
|
+
if (!suggestions) return null
|
|
427
|
+
|
|
428
|
+
const canWrite = typeof inputActions?.setDraft === 'function'
|
|
429
|
+
const chip = (suggestion, index) => {
|
|
430
|
+
const usage = usages[index]
|
|
431
|
+
const marks = [
|
|
432
|
+
suggestion.recommended && t('chip.recommended'),
|
|
433
|
+
suggestion.danger && t('chip.danger'),
|
|
434
|
+
usage === 'full' && t('chip.sent'),
|
|
435
|
+
usage === 'partial' && t('chip.edited'),
|
|
436
|
+
].filter(Boolean)
|
|
437
|
+
// One leading icon: what the user did outranks what the model said.
|
|
438
|
+
const icon = usage !== null
|
|
439
|
+
? h(usage === 'full' ? CheckIcon : EditedIcon)
|
|
440
|
+
: suggestion.danger ? h(WarnIcon) : suggestion.recommended ? h(SparkIcon) : null
|
|
441
|
+
return h('button', {
|
|
442
|
+
key: index,
|
|
443
|
+
type: 'button',
|
|
444
|
+
className: 'dshna-chip',
|
|
445
|
+
'data-recommended': suggestion.recommended ? '' : undefined,
|
|
446
|
+
'data-used': usage ?? undefined,
|
|
447
|
+
disabled: !canWrite,
|
|
448
|
+
title: suggestion.prompt,
|
|
449
|
+
'aria-label': marks.length > 0
|
|
450
|
+
? t('chip.ariaMarked', { label: suggestion.label, marks: marks.join(t('chip.markSeparator')), prompt: suggestion.prompt })
|
|
451
|
+
: t('chip.aria', { label: suggestion.label, prompt: suggestion.prompt }),
|
|
452
|
+
onClick: () => {
|
|
453
|
+
ownedRef.current = fill(inputActions, draft, ownedRef.current, suggestion.prompt, sessionId)
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
icon,
|
|
457
|
+
h('span', { className: 'dshna-chip-label' }, suggestion.label))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return h('div', { className: 'dshna-reveal', 'data-fresh': isFresh(turn) ? '' : undefined },
|
|
461
|
+
h('section', { className: 'dshna-card', 'aria-label': t('card.title') },
|
|
462
|
+
h('p', { className: 'dshna-context' }, h(Mark), model.context),
|
|
463
|
+
h('div', { className: 'dshna-chips' }, suggestions.map(chip))))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// -----------------------------------------------------------------------
|
|
467
|
+
// Process row (one line in the tool group, like the official propose_action row)
|
|
468
|
+
// -----------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
function rowState(phase, block) {
|
|
471
|
+
if (phase === 'preparing' || phase === 'start') return 'running'
|
|
472
|
+
if (block?.isError === true || block?.error !== undefined) return 'error'
|
|
473
|
+
return block?.meta?.shown === false ? 'error' : 'ok'
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function ProcessRow({ phase, block, t }) {
|
|
477
|
+
const state = rowState(phase, block)
|
|
478
|
+
const count = state === 'ok' && Array.isArray(block?.meta?.suggestions) ? block.meta.suggestions.length : 0
|
|
479
|
+
return h('div', { className: 'dshna-row', 'data-tool': TOOL_NAME, 'data-state': state, title: block?.meta?.note },
|
|
480
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1, 'aria-hidden': 'true' },
|
|
481
|
+
h('rect', { x: 2.25, y: 2.25, width: 4, height: 4, rx: 1 }),
|
|
482
|
+
h('rect', { x: 2.25, y: 9.75, width: 4, height: 4, rx: 1 }),
|
|
483
|
+
h('rect', { x: 9.75, y: 9.75, width: 4, height: 4, rx: 1 }),
|
|
484
|
+
h('path', { d: 'M11.75 1.75v5M9.25 4.25h5' })),
|
|
485
|
+
h('span', { className: 'dshna-row-title' }, t('row.title')),
|
|
486
|
+
h('span', { className: 'dshna-row-summary' }, count > 0 ? `${t(`row.${state}`)} · ${count}` : t(`row.${state}`)))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// -----------------------------------------------------------------------
|
|
490
|
+
// Plugin entry
|
|
491
|
+
// -----------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
function apply(ctx) {
|
|
494
|
+
sharedCtx = ctx
|
|
495
|
+
ctx.effect(() => ctx.locale.register(NS, { en: EN, zh: ZH }), 'dsh-next-actions: locale')
|
|
496
|
+
ctx.effect(() => {
|
|
497
|
+
const element = document.createElement('style')
|
|
498
|
+
element.dataset.plugin = PKG
|
|
499
|
+
element.textContent = STYLES
|
|
500
|
+
document.head.appendChild(element)
|
|
501
|
+
return () => element.remove()
|
|
502
|
+
}, 'dsh-next-actions: styles')
|
|
503
|
+
ctx.effect(() => ctx.uiConversation.events.register(cardDefinition), 'dsh-next-actions: card definition')
|
|
504
|
+
ctx.effect(() => ctx.uiConversation.events.register(sentDefinition), 'dsh-next-actions: sent definition')
|
|
505
|
+
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
|
506
|
+
name: 'conversation.chat.turnTail', id: PKG, order: -10, locale: NS,
|
|
507
|
+
}, NextActionsCard))
|
|
508
|
+
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
|
509
|
+
name: 'tool.call.toolview', key: TOOL_NAME, locale: NS,
|
|
510
|
+
}, ProcessRow))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
// `uiConversation` is injected so the definition registers on a cold boot.
|
|
515
|
+
inject: ['slots', 'locale', 'uiConversation'],
|
|
516
|
+
apply,
|
|
517
|
+
__test: { cardDefinition, sentDefinition, modelOf, fillMode, fill, rowState, usageOf, nextSent },
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="next-actions-fill" x1="5" y1="8" x2="26" y2="20" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop stop-color="#67C7FE"/>
|
|
5
|
+
<stop offset="1" stop-color="#3F77D8"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<g transform="translate(-1.5 0.5)">
|
|
9
|
+
<rect x="5" y="7.6" width="21" height="5.4" rx="2.7" fill="url(#next-actions-fill)"/>
|
|
10
|
+
<rect x="5" y="15.3" width="16" height="5.4" rx="2.7" fill="#7AAEFF"/>
|
|
11
|
+
<rect x="5" y="23" width="11" height="5.4" rx="2.7" fill="#9CC3FF"/>
|
|
12
|
+
<path d="M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z" fill="#41E1AC"/>
|
|
13
|
+
</g>
|
|
14
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-next-actions`.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `suggest_next_actions` tool for root agents plus one standing
|
|
5
|
+
* prompt section that tells the model when to call it. Zero imports: the
|
|
6
|
+
* package is installed with `link:` and cannot resolve `@deepseek-ai/*`.
|
|
7
|
+
* Row config `enabled: false` registers nothing, so the feature costs no tokens.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { buildMeta, LIMITS } from './lib/normalize.js'
|
|
11
|
+
|
|
12
|
+
export const name = 'dsh-next-actions'
|
|
13
|
+
export const inject = ['tools', 'systemPrompt', 'agents']
|
|
14
|
+
|
|
15
|
+
const TOOL_NAME = 'suggest_next_actions'
|
|
16
|
+
|
|
17
|
+
const GUIDANCE = [
|
|
18
|
+
'Before your final answer, call suggest_next_actions once when this turn leaves real follow-up work:',
|
|
19
|
+
'a task finished or advanced, tools ran, files changed, review or approval is due,',
|
|
20
|
+
'or you were about to end with a list of next actions.',
|
|
21
|
+
'Put those actions only in the card; the reply gets at most one lead-in sentence and never numbers or bullets them.',
|
|
22
|
+
'Skip greetings, acknowledgements, pure knowledge answers, repeated cards, and turns that already asked a structured question.',
|
|
23
|
+
].join(' ')
|
|
24
|
+
|
|
25
|
+
const PROMPT_SECTION = 'tool:suggest-next-actions'
|
|
26
|
+
|
|
27
|
+
const TOOL = {
|
|
28
|
+
name: TOOL_NAME,
|
|
29
|
+
description: [
|
|
30
|
+
'Show 1–3 clickable next actions as a card under your reply; clicking one fills the message input, never sends.',
|
|
31
|
+
"Write them in the user's language and never repeat them in your prose.",
|
|
32
|
+
].join(' '),
|
|
33
|
+
parameters: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
required: ['context', 'suggestions'],
|
|
37
|
+
properties: {
|
|
38
|
+
context: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'One or two sentences (≤240 chars): what was just done and why these actions follow. Never restate the answer.',
|
|
41
|
+
},
|
|
42
|
+
suggestions: {
|
|
43
|
+
type: 'array',
|
|
44
|
+
description: '1–3 distinct next actions, most useful first.',
|
|
45
|
+
items: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
required: ['label', 'prompt'],
|
|
49
|
+
properties: {
|
|
50
|
+
label: { type: 'string', description: `Short imperative button text, ≤${LIMITS.label} chars, no ending punctuation.` },
|
|
51
|
+
prompt: { type: 'string', description: "Message the user could send as-is (≤500 chars), citing this turn's files, commands or decisions." },
|
|
52
|
+
recommended: { type: 'boolean', description: 'At most one: your pick when one action is clearly best.' },
|
|
53
|
+
danger: { type: 'boolean', description: 'True only for destructive, irreversible, paid or permission-changing actions, e.g. 删除/清空/重置/发布/部署/回滚/付费/授权/卸载.' },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
output: {
|
|
60
|
+
schema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
required: ['shown', 'count'],
|
|
64
|
+
properties: { shown: { type: 'boolean' }, count: { type: 'integer' } },
|
|
65
|
+
},
|
|
66
|
+
render: (_args, value) => [{
|
|
67
|
+
type: 'text',
|
|
68
|
+
text: value?.shown
|
|
69
|
+
? `Next actions shown below your answer (${value.count}). Now write the final answer: do not list them, keep at most one lead-in sentence, and do not call this tool again this turn.`
|
|
70
|
+
: 'No card shown. Answer normally; do not mention this tool.',
|
|
71
|
+
}],
|
|
72
|
+
// Persisted as tool/result.meta; the browser renders the card from it.
|
|
73
|
+
presentationMeta: (args) => buildMeta(args),
|
|
74
|
+
},
|
|
75
|
+
execute(args) {
|
|
76
|
+
const meta = buildMeta(args)
|
|
77
|
+
return { shown: meta.shown, count: meta.shown ? meta.suggestions.length : 0 }
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Only a literal `enabled: false` turns the feature off. */
|
|
82
|
+
export function normalizeConfig(raw) {
|
|
83
|
+
return { enabled: !(raw !== null && typeof raw === 'object' && raw.enabled === false) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Root agents only; fail open so a Harness change never silently hides the tool. */
|
|
87
|
+
function isRoot(ctx, agent) {
|
|
88
|
+
if (agent === undefined || agent === null) return true
|
|
89
|
+
try {
|
|
90
|
+
return ctx.agents.roots().includes(agent)
|
|
91
|
+
} catch {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function apply(ctx, config) {
|
|
97
|
+
if (!normalizeConfig(config).enabled) return
|
|
98
|
+
|
|
99
|
+
ctx.effect(
|
|
100
|
+
() => ctx.tools.register(TOOL, { when: ({ agent }) => isRoot(ctx, agent) }),
|
|
101
|
+
'dsh-next-actions: tool',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/** agent → disposer of its prompt section. */
|
|
105
|
+
const installed = new Map()
|
|
106
|
+
const install = (agent) => {
|
|
107
|
+
if (installed.has(agent)) return
|
|
108
|
+
try {
|
|
109
|
+
if (!ctx.agents.roots().includes(agent)) return
|
|
110
|
+
const prompt = agent.ctx.systemPrompt
|
|
111
|
+
const base = prompt.getSectionOrder?.('TOOL_REPORT')
|
|
112
|
+
installed.set(agent, agent.ctx.effect(() => prompt.section({
|
|
113
|
+
name: PROMPT_SECTION,
|
|
114
|
+
order: typeof base === 'number' ? base + 50 : 2950,
|
|
115
|
+
text: GUIDANCE,
|
|
116
|
+
}), 'dsh-next-actions: guidance'))
|
|
117
|
+
} catch {
|
|
118
|
+
// The guidance is an enhancement; the tool stays registered.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ctx.effect(() => {
|
|
123
|
+
const offCreated = ctx.on('agent/created', ({ agent }) => install(agent))
|
|
124
|
+
const offDisposed = ctx.on('agent/disposed', ({ agent }) => installed.delete(agent))
|
|
125
|
+
try {
|
|
126
|
+
for (const agent of ctx.agents.roots()) install(agent)
|
|
127
|
+
} catch {
|
|
128
|
+
// Agents created later still arrive through agent/created.
|
|
129
|
+
}
|
|
130
|
+
return () => {
|
|
131
|
+
offCreated()
|
|
132
|
+
offDisposed()
|
|
133
|
+
for (const dispose of installed.values()) {
|
|
134
|
+
try {
|
|
135
|
+
dispose()
|
|
136
|
+
} catch {
|
|
137
|
+
// The agent may already be gone.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
installed.clear()
|
|
141
|
+
}
|
|
142
|
+
}, 'dsh-next-actions: guidance')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test seams. */
|
|
146
|
+
export const __definition = TOOL
|
|
147
|
+
export const __guidance = { section: PROMPT_SECTION, text: GUIDANCE }
|
package/lib/normalize.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize `suggest_next_actions` arguments into the card model.
|
|
3
|
+
*
|
|
4
|
+
* Host only: the result is persisted as `tool/result.meta`, which the browser
|
|
5
|
+
* half renders as-is. Import-free and side-effect-free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const LIMITS = Object.freeze({ suggestions: 3, context: 240, label: 24, prompt: 500 })
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Command-shaped risk patterns: a hit marks a suggestion as needing confirmation.
|
|
12
|
+
* Clicking still only fills the input, so the mark never blocks the user.
|
|
13
|
+
*/
|
|
14
|
+
export const RISK = /\brm\s+-rf\b|\bforce[- ]?push\b|--force\b|\bdrop\s+(table|database)\b|\btruncate\s+table\b|\bsudo\b|\bformat\s+(disk|drive|volume)\b/i
|
|
15
|
+
|
|
16
|
+
const text = (value) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '')
|
|
17
|
+
|
|
18
|
+
function clip(value, max) {
|
|
19
|
+
const characters = Array.from(value)
|
|
20
|
+
return characters.length <= max ? value : `${characters.slice(0, max - 1).join('')}…`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} args - tool arguments as parsed by the Host.
|
|
25
|
+
* @returns {{ shown: true, context: string, suggestions: { label: string, prompt: string, danger?: true }[] } | { shown: false, note: string }}
|
|
26
|
+
*/
|
|
27
|
+
export function buildMeta(args) {
|
|
28
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) return { shown: false, note: 'invalid-arguments' }
|
|
29
|
+
|
|
30
|
+
const context = clip(text(args.context), LIMITS.context)
|
|
31
|
+
if (context === '') return { shown: false, note: 'missing-context' }
|
|
32
|
+
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const entries = []
|
|
35
|
+
let recommended = null
|
|
36
|
+
for (const item of Array.isArray(args.suggestions) ? args.suggestions : []) {
|
|
37
|
+
if (item === null || typeof item !== 'object') continue
|
|
38
|
+
const prompt = clip(text(item.prompt) || text(item.label), LIMITS.prompt)
|
|
39
|
+
const key = prompt.toLowerCase()
|
|
40
|
+
if (prompt === '' || seen.has(key)) continue
|
|
41
|
+
seen.add(key)
|
|
42
|
+
const label = clip(text(item.label) || prompt, LIMITS.label)
|
|
43
|
+
const entry = { label, prompt }
|
|
44
|
+
if (item.danger === true || RISK.test(`${label}\n${prompt}`)) entry.danger = true
|
|
45
|
+
// At most one recommendation; it always leads the card.
|
|
46
|
+
if (item.recommended === true && recommended === null) {
|
|
47
|
+
entry.recommended = true
|
|
48
|
+
recommended = entry
|
|
49
|
+
} else {
|
|
50
|
+
entries.push(entry)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const suggestions = recommended === null ? entries : [recommended, ...entries]
|
|
54
|
+
if (suggestions.length === 0) return { shown: false, note: 'no-usable-suggestions' }
|
|
55
|
+
return { shown: true, context, suggestions: suggestions.slice(0, LIMITS.suggestions) }
|
|
56
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-next-actions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Shows 1–3 clickable next actions under replies; clicking one fills the input draft, never sends.",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"author": "LighghtEeloo",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/LighghtEeloo/dsh-next-actions.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/LighghtEeloo/dsh-next-actions#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/LighghtEeloo/dsh-next-actions/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"deepseek-harness",
|
|
19
|
+
"dsh",
|
|
20
|
+
"dsh-plugin",
|
|
21
|
+
"next-actions",
|
|
22
|
+
"suggestions"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"dsh": ">=0.2.2-rc.1"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./index.js",
|
|
32
|
+
"./client": "./client.js",
|
|
33
|
+
"./locale/*.json": "./locale/*.json",
|
|
34
|
+
"./icon": "./icon.svg"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"client.js",
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"lib/normalize.js",
|
|
41
|
+
"locale/*.json",
|
|
42
|
+
"icon.svg",
|
|
43
|
+
"DESIGN.md",
|
|
44
|
+
"assets/*.svg",
|
|
45
|
+
"README.md",
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"NOTICE"
|
|
49
|
+
],
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"platform": "web",
|
|
56
|
+
"immediately": true,
|
|
57
|
+
"inject": [
|
|
58
|
+
"@deepseek-ai/dsh-client-locale",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-chat",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-tool"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"test": "node --test"
|
|
67
|
+
}
|
|
68
|
+
}
|