dsh-predict 0.0.0-stage → 1.2.1
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/LICENSE +21 -0
- package/README.md +191 -2
- package/client.js +353 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +6 -0
- package/index.js +377 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dogtiti
|
|
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/README.md
CHANGED
|
@@ -1,3 +1,192 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-predict
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-predict)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
6
|
+
|
|
7
|
+
基于当前对话预测下一条用户指令,直接呈现在输入框的占位文字中;按 `Tab` 采纳为可编辑草稿。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 效果
|
|
12
|
+
|
|
13
|
+
会话空闲且输入框为空时,原生提示文字被替换为一条建议:
|
|
14
|
+
|
|
15
|
+
```diff
|
|
16
|
+
- 发消息或创建任务, / 调用指令, @ 文件或对话
|
|
17
|
+
+ 跑一下测试
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`Tab` 或点击可填入输入框(光标位于末尾),不会自动发送。
|
|
21
|
+
|
|
22
|
+
建议由当前会话正在使用的模型生成:
|
|
23
|
+
|
|
24
|
+
| 上下文 | 建议 |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| 刚完成一次代码修改 | `跑一下看看效果` |
|
|
27
|
+
| 测试报告显示 3 个失败用例 | `先修掉这三个失败用例` |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
**市场索引**
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
https://raw.githubusercontent.com/Dogtiti/dsh-predict/main/market-index.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
设置 → 插件 → 启用实验性市场 bundle → Discover → 添加上面的地址 → 安装 `dsh-predict`。
|
|
40
|
+
|
|
41
|
+
**npm**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm i dsh-predict
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
在 Plugins 页用「添加」填写包名。
|
|
48
|
+
|
|
49
|
+
**本地目录**
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/Dogtiti/dsh-predict
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Plugins → 添加 → 选择该目录。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 行为
|
|
60
|
+
|
|
61
|
+
建议仅在以下条件全部满足时请求并显示:
|
|
62
|
+
|
|
63
|
+
- 该会话已完成至少一轮对话
|
|
64
|
+
- 会话不在运行中
|
|
65
|
+
- 输入框为空、无待发送附件、无已认领的斜杠命令
|
|
66
|
+
- 非一次性子会话(只读输入框)
|
|
67
|
+
|
|
68
|
+
不接管的情况:
|
|
69
|
+
|
|
70
|
+
| 条件 | 行为 |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| 输入框非空 | 不请求、不显示 |
|
|
73
|
+
| 会话运行中 | 不显示 |
|
|
74
|
+
| `/` 或 `@` 菜单弹出 | 不显示 |
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 配置
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
- insert:
|
|
82
|
+
- id: composer-predict
|
|
83
|
+
name: 'dsh-predict'
|
|
84
|
+
config:
|
|
85
|
+
model: deepseek-chat
|
|
86
|
+
maxSuggestionChars: 24
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| 键 | 默认 | 说明 |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `enabled` | `true` | 总开关;关闭后路由返回 503,客户端不再请求 |
|
|
92
|
+
| `provider` / `model` | `''` | 指定模型;留空跟随当前会话 |
|
|
93
|
+
| `maxContextMessages` | `8` | 送入模型的消息条数(取尾部) |
|
|
94
|
+
| `maxMessageChars` | `600` | 单条消息最大字符数(保留头尾) |
|
|
95
|
+
| `maxSuggestionChars` | `32` | 建议的目标长度 |
|
|
96
|
+
| `maxTokens` | `200` | 输出上限 |
|
|
97
|
+
| `timeoutMs` | `20000` | 单次请求超时 |
|
|
98
|
+
| `cacheTtlMs` | `60000` | 相同会话事件序号下的缓存时间 |
|
|
99
|
+
| `diagnostics` | `false` | 将客户端判定写入 `.diagnostics.jsonl`,用于排障 |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 实现
|
|
104
|
+
|
|
105
|
+
插件为标准的 DSH bundle,包含 Host 与 Client 两部分:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
┌──────────────── 浏览器 ────────────────┐ ┌───────────── Host ─────────────┐
|
|
109
|
+
│ conversation.composer.dock │ │ POST /api/composer.predict │
|
|
110
|
+
│ · 回合结束时请求(running: true→false) │ HTTP │ · sessions.get(id) │
|
|
111
|
+
│ · 重绘 [data-composer-placeholder] │ ─────► │ · session.deriveMessages() │
|
|
112
|
+
│ · 自检:内容包含建议且字号 ≥ 8px │ ◄───── │ · 当前会话 provider/model │
|
|
113
|
+
│ · Tab → inputActions.setDraft() │ JSON │ · 按 seq 缓存 60s │
|
|
114
|
+
└────────────────────────────────────────┘ └────────────────────────────────┘
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
宿主占位元素已提供定位(`inset:4px 8px auto 14px`)、字号、行高、省略号与 IME 组合期隐藏,插件只改写其内容:
|
|
118
|
+
|
|
119
|
+
```css
|
|
120
|
+
[data-composer-placeholder] { color: transparent }
|
|
121
|
+
[data-composer-placeholder]::after {
|
|
122
|
+
content: "跑一下测试";
|
|
123
|
+
position: absolute; inset: 0;
|
|
124
|
+
font: inherit;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
不使用 `font-size: 0` 配合变量恢复字号:变量解析失败时字号会退化为 0,文字存在但不可见。`font: inherit` 不依赖变量。
|
|
129
|
+
|
|
130
|
+
挂载后读取 `getComputedStyle(target, '::after')` 自检,若内容不含建议或字号小于 8px,回退为输入框下方一行并在控制台输出 `[composer-predict]` 警告。
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 数据与开销
|
|
135
|
+
|
|
136
|
+
- 读取本会话日志尾部(用户与助手文本、工具名、工作目录),发送至会话自身配置的模型服务与凭据。
|
|
137
|
+
- 每轮一次独立小请求,默认上限 200 tokens;`enabled: false` 可关闭。
|
|
138
|
+
- 结果不落盘,不写入会话日志。
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 开发
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
node --test "test/*.test.mjs"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
14 项:提示词拼装、模型路由回退、输出清洗、缓存失效、开关、CSS 转义、门禁条件、兜底渲染。
|
|
149
|
+
|
|
150
|
+
`test/render-proof/` 将插件实际生成的 CSS 应用于宿主占位结构,用无头 Chrome 渲染并截图,用于核对重绘结果,包含字号变量非法的边界环境。
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 兼容性
|
|
155
|
+
|
|
156
|
+
- DSH `^0.2.1-0`(`engines.dsh`)。
|
|
157
|
+
- 依赖宿主占位元素的 `[data-composer-placeholder]` 标记;缺失时走回退路径,不影响输入框本身。
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 相关插件
|
|
162
|
+
|
|
163
|
+
| 插件 | 说明 |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| **dsh-predict**(本仓库) | 预测下一条指令并显示为占位文字,`Tab` 采纳 |
|
|
166
|
+
| [dsh-plugin-composer-history](https://github.com/Dogtiti/dsh-plugin-composer-history) | 输入框中按 `↑` / `↓` 回填历史消息 |
|
|
167
|
+
| [dsh-plugin-subagent-bridge](https://github.com/Dogtiti/dsh-plugin-subagent-bridge) | 让 Codex 通过本机 MCP 把 DSH 当 subagent 用(spawn / wait / followup),反向也能把任务派给 Codex |
|
|
168
|
+
| [dsh-plugin-usage-panel](https://github.com/Dogtiti/dsh-plugin-usage-panel) | 在模型选择器旁显示供应商套餐额度余量(目前 GLM Coding Plan),点开是明细浮层 |
|
|
169
|
+
| [dsh-plugin-provider-icons](https://github.com/Dogtiti/dsh-plugin-provider-icons) | 在模型名旁显示供应商品牌 logo(24 个厂商,支持前缀匹配) |
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
## 许可
|
|
173
|
+
|
|
174
|
+
MIT © [Dogtiti](https://github.com/Dogtiti)
|
|
175
|
+
|
|
176
|
+
## 诊断
|
|
177
|
+
|
|
178
|
+
`diagnostics: true` 会把每次预测的决策写进包目录下的 `.diagnostics.jsonl`:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
{"event":"predict-request","sessionId":"…","found":true,"transcriptLength":3217}
|
|
182
|
+
{"event":"predict-done","sessionId":"…","suggestion":"跑一下测试","raw":"跑一下测试"}
|
|
183
|
+
{"event":"predict-error","sessionId":"…","message":"…"}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
用来回答"为什么输入框没有预测":`found:false` = 会话没挂载;`transcriptLength` 太小 = 可参考的上下文太少;`suggestion:null` + `raw:"NONE"` = 模型判定无法预测。
|
|
187
|
+
|
|
188
|
+
## 没有就不显示
|
|
189
|
+
|
|
190
|
+
提示词不再让模型输出 `NONE` 之类的占位词:**有具体任务就给一条,没有就什么都不输出**。插件侧再把少数模型"把沉默写成文字"的情况(`NONE` / `无` / `什么都不输出` / `空回复`)统一当成"没有建议",输入框保持原样。
|
|
191
|
+
|
|
192
|
+
实测同一个真实工作会话:旧提示词返回 `NONE`,新提示词返回「停止重复调用,改为汇总当前验证结果」;只有问候的对话则保持空白。
|
package/client.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of the composer-prediction bundle.
|
|
3
|
+
*
|
|
4
|
+
* Primary surface: the prediction repaints the composer's own placeholder, so the
|
|
5
|
+
* suggestion appears exactly where the user types. If that repaint cannot be applied
|
|
6
|
+
* (the shipped placeholder markup moved, or a style policy blocks the injected rule),
|
|
7
|
+
* the component falls back to a compact row under the composer instead of showing
|
|
8
|
+
* nothing. Tab fills the prediction in as an ordinary editable draft either way.
|
|
9
|
+
*/
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: 'dsh-predict',
|
|
12
|
+
factory(require) {
|
|
13
|
+
const React = require('react')
|
|
14
|
+
const h = React.createElement
|
|
15
|
+
|
|
16
|
+
/** Route registered by the Host half, addressed document-relatively. */
|
|
17
|
+
const ROUTE = 'api/composer.predict'
|
|
18
|
+
|
|
19
|
+
/** Diagnostic route; the Host half appends one line per report. */
|
|
20
|
+
const REPORT = 'api/composer.report'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The shipped composer placeholder element. Its geometry, typography, IME hiding,
|
|
24
|
+
* and empty-draft lifetime are the host's; this plugin only repaints its content.
|
|
25
|
+
*/
|
|
26
|
+
const PLACEHOLDER = '[data-composer-placeholder]'
|
|
27
|
+
|
|
28
|
+
/** Inline styles for the fallback row, so it renders even when `<style>` is blocked. */
|
|
29
|
+
const ROW = {
|
|
30
|
+
row: {
|
|
31
|
+
display: 'flex',
|
|
32
|
+
alignItems: 'center',
|
|
33
|
+
justifyContent: 'flex-end',
|
|
34
|
+
gap: '4px',
|
|
35
|
+
minWidth: '0',
|
|
36
|
+
padding: '0 2px',
|
|
37
|
+
fontFamily: 'inherit',
|
|
38
|
+
},
|
|
39
|
+
hint: {
|
|
40
|
+
display: 'inline-flex',
|
|
41
|
+
alignItems: 'center',
|
|
42
|
+
minWidth: '0',
|
|
43
|
+
maxWidth: '100%',
|
|
44
|
+
padding: '1px 6px',
|
|
45
|
+
border: 'none',
|
|
46
|
+
borderRadius: '8px',
|
|
47
|
+
background: 'transparent',
|
|
48
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
49
|
+
fontSize: '12px',
|
|
50
|
+
lineHeight: '18px',
|
|
51
|
+
cursor: 'pointer',
|
|
52
|
+
textAlign: 'left',
|
|
53
|
+
},
|
|
54
|
+
text: {
|
|
55
|
+
minWidth: '0',
|
|
56
|
+
overflow: 'hidden',
|
|
57
|
+
textOverflow: 'ellipsis',
|
|
58
|
+
whiteSpace: 'nowrap',
|
|
59
|
+
},
|
|
60
|
+
dismiss: {
|
|
61
|
+
flex: 'none',
|
|
62
|
+
display: 'grid',
|
|
63
|
+
placeItems: 'center',
|
|
64
|
+
width: '18px',
|
|
65
|
+
height: '18px',
|
|
66
|
+
padding: '0',
|
|
67
|
+
border: 'none',
|
|
68
|
+
borderRadius: '999px',
|
|
69
|
+
background: 'transparent',
|
|
70
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
71
|
+
fontSize: '12px',
|
|
72
|
+
lineHeight: '1',
|
|
73
|
+
cursor: 'pointer',
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a transport or repaint problem was already reported in this page. */
|
|
78
|
+
let warned = false
|
|
79
|
+
|
|
80
|
+
/** Whether an empty prediction was already reported in this page. */
|
|
81
|
+
let quiet = false
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Escape one string for a double-quoted CSS `content` value.
|
|
85
|
+
* @param text - plain text.
|
|
86
|
+
* @returns the escaped value.
|
|
87
|
+
*/
|
|
88
|
+
function cssString(text) {
|
|
89
|
+
return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n\t]/g, ' ')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the rule set that repaints the composer placeholder.
|
|
94
|
+
* The host's element already owns the geometry, font, ellipsis, and IME hiding;
|
|
95
|
+
* this only swaps its text for the prediction.
|
|
96
|
+
* @param text - predicted instruction.
|
|
97
|
+
* @returns CSS text.
|
|
98
|
+
*/
|
|
99
|
+
function ghostCss(text) {
|
|
100
|
+
return [
|
|
101
|
+
/* Keep the host's text node (its layout is already right) but make it invisible;
|
|
102
|
+
* the repaint is an absolute overlay, so it needs no font-size trick and no CSS
|
|
103
|
+
* variable that may not resolve to a length. */
|
|
104
|
+
`${PLACEHOLDER}{color:transparent!important;}`,
|
|
105
|
+
`${PLACEHOLDER}::after{content:"${cssString(text)}";position:absolute;inset:0;color:var(--dsw-alias-label-caption,#9a9a9a);font:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}`,
|
|
106
|
+
].join('')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Send one diagnostic snapshot to the Host half. Never throws.
|
|
111
|
+
* @param payload - plain JSON snapshot.
|
|
112
|
+
*/
|
|
113
|
+
async function sendReport(payload) {
|
|
114
|
+
try {
|
|
115
|
+
await fetch(new URL(REPORT, document.baseURI), {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/json' },
|
|
118
|
+
body: JSON.stringify(payload),
|
|
119
|
+
credentials: 'same-origin',
|
|
120
|
+
})
|
|
121
|
+
} catch {
|
|
122
|
+
/* Diagnostics never affect the feature. */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ask the Host half for this Session's prediction.
|
|
128
|
+
* @param sessionId - Session to predict for.
|
|
129
|
+
* @param signal - cancellation.
|
|
130
|
+
* @returns the instruction, or null when none is available.
|
|
131
|
+
*/
|
|
132
|
+
async function requestSuggestion(sessionId, signal) {
|
|
133
|
+
const response = await fetch(new URL(ROUTE, document.baseURI), {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ sessionId }),
|
|
137
|
+
credentials: 'same-origin',
|
|
138
|
+
signal,
|
|
139
|
+
})
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
if (warned !== true && response.status !== 404) {
|
|
142
|
+
warned = true
|
|
143
|
+
console.warn(`[composer-predict] prediction route answered ${response.status}`)
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
const data = await response.json()
|
|
148
|
+
const suggestion = data?.suggestion
|
|
149
|
+
if (typeof suggestion !== 'string' || suggestion.trim() === '') {
|
|
150
|
+
if (quiet !== true) {
|
|
151
|
+
quiet = true
|
|
152
|
+
console.info('[composer-predict] the host returned no suggestion', data)
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
return suggestion.trim()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Guard the slot's standard props before any hook runs.
|
|
161
|
+
* @param props - slot standard props.
|
|
162
|
+
* @returns the painter, or null when this slot lacks the composer hooks.
|
|
163
|
+
*/
|
|
164
|
+
function PlaceholderPainter(props) {
|
|
165
|
+
if (typeof props.useInput !== 'function' || typeof props.useSession !== 'function') return null
|
|
166
|
+
if (props.inputActions === undefined || props.inputActions === null) return null
|
|
167
|
+
if (typeof props.sessionId !== 'string' || props.sessionId === '') return null
|
|
168
|
+
return h(PainterBody, props)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Own the prediction lifecycle and paint it, in-box when possible.
|
|
173
|
+
* @param props - slot standard props.
|
|
174
|
+
* @returns the painted style element, a fallback row, or null.
|
|
175
|
+
*/
|
|
176
|
+
function PainterBody(props) {
|
|
177
|
+
const { sessionId, useSession, useInput, inputActions } = props
|
|
178
|
+
const draft = useInput((snapshot) => snapshot.draft)
|
|
179
|
+
const attachmentCount = useInput((snapshot) => snapshot.attachmentIds.length)
|
|
180
|
+
const phase = useInput((snapshot) => snapshot.phase)
|
|
181
|
+
const running = useSession((snapshot) => snapshot.running)
|
|
182
|
+
const blank = useSession((snapshot) => snapshot.blank)
|
|
183
|
+
const removed = useSession((snapshot) => snapshot.removed)
|
|
184
|
+
const subagentMode = useSession((snapshot) => snapshot.subagent?.address?.mode ?? null)
|
|
185
|
+
|
|
186
|
+
const [generation, setGeneration] = React.useState(0)
|
|
187
|
+
const [entry, setEntry] = React.useState(null)
|
|
188
|
+
const [surface, setSurface] = React.useState('placeholder')
|
|
189
|
+
const anchorRef = React.useRef(null)
|
|
190
|
+
const previousRunning = React.useRef(running === true)
|
|
191
|
+
|
|
192
|
+
React.useEffect(() => {
|
|
193
|
+
const wasRunning = previousRunning.current
|
|
194
|
+
previousRunning.current = running === true
|
|
195
|
+
if (wasRunning && running !== true) setGeneration((value) => value + 1)
|
|
196
|
+
}, [running])
|
|
197
|
+
|
|
198
|
+
const idle = running !== true && blank !== true && removed !== true
|
|
199
|
+
const writable = subagentMode === null || subagentMode === 'continuable'
|
|
200
|
+
const key = `${sessionId}:${generation}`
|
|
201
|
+
|
|
202
|
+
React.useEffect(() => {
|
|
203
|
+
if (!idle || !writable) return undefined
|
|
204
|
+
const controller = new AbortController()
|
|
205
|
+
let live = true
|
|
206
|
+
setEntry((current) => (current?.key === key ? current : { key, text: null }))
|
|
207
|
+
requestSuggestion(sessionId, controller.signal)
|
|
208
|
+
.then((text) => {
|
|
209
|
+
if (live) setEntry({ key, text })
|
|
210
|
+
})
|
|
211
|
+
.catch(() => {
|
|
212
|
+
if (live) setEntry({ key, text: null })
|
|
213
|
+
})
|
|
214
|
+
return () => {
|
|
215
|
+
live = false
|
|
216
|
+
controller.abort()
|
|
217
|
+
}
|
|
218
|
+
}, [idle, writable, key, sessionId])
|
|
219
|
+
|
|
220
|
+
const suggestion = entry?.key === key ? entry.text : null
|
|
221
|
+
/* The host renders its placeholder under exactly these conditions; matching them
|
|
222
|
+
* keeps the repaint attached to a real element instead of racing it. */
|
|
223
|
+
const paints = typeof suggestion === 'string' && suggestion !== '' && draft === '' && attachmentCount === 0 && phase === 'plain' && idle && writable
|
|
224
|
+
|
|
225
|
+
/* Verify the repaint after it mounts, and drop to the row when it did not land. */
|
|
226
|
+
React.useEffect(() => {
|
|
227
|
+
if (!paints) {
|
|
228
|
+
setSurface('placeholder')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (surface === 'row') return
|
|
232
|
+
const dock = anchorRef.current?.closest('[data-composer-dock]')
|
|
233
|
+
const target = dock?.parentElement?.querySelector(PLACEHOLDER)
|
|
234
|
+
const after = target === null || target === undefined ? null : getComputedStyle(target, '::after')
|
|
235
|
+
const painted = after === null ? '' : String(after.content ?? '')
|
|
236
|
+
const sized = after === null ? 0 : Number.parseFloat(after.fontSize)
|
|
237
|
+
const landed = painted !== '' && painted !== 'none' && painted !== 'normal' && painted.includes(suggestion) && sized >= 8
|
|
238
|
+
if (landed) return
|
|
239
|
+
setSurface('row')
|
|
240
|
+
if (warned !== true) {
|
|
241
|
+
warned = true
|
|
242
|
+
console.warn('[composer-predict] the composer placeholder could not be repainted; showing the row instead')
|
|
243
|
+
}
|
|
244
|
+
}, [paints, surface, suggestion])
|
|
245
|
+
|
|
246
|
+
/* One diagnostic line per distinct decision, so a silent composer is explainable. */
|
|
247
|
+
const reportRef = React.useRef('')
|
|
248
|
+
React.useEffect(() => {
|
|
249
|
+
const signature = `${key}|${suggestion ?? ''}|${paints}|${draft === ''}|${phase}`
|
|
250
|
+
if (reportRef.current === signature) return
|
|
251
|
+
reportRef.current = signature
|
|
252
|
+
const dock = anchorRef.current?.closest('[data-composer-dock]') ?? document.querySelector('[data-composer-dock]')
|
|
253
|
+
const target = dock?.parentElement?.querySelector(PLACEHOLDER) ?? document.querySelector(PLACEHOLDER)
|
|
254
|
+
const after = target === null || target === undefined ? '' : String(getComputedStyle(target, '::after').content ?? '')
|
|
255
|
+
void sendReport({
|
|
256
|
+
sessionId,
|
|
257
|
+
generation,
|
|
258
|
+
surface,
|
|
259
|
+
paints,
|
|
260
|
+
hasText: typeof suggestion === 'string' && suggestion !== '',
|
|
261
|
+
text: typeof suggestion === 'string' ? suggestion.slice(0, 60) : '',
|
|
262
|
+
draftLength: typeof draft === 'string' ? draft.length : -1,
|
|
263
|
+
phase,
|
|
264
|
+
running,
|
|
265
|
+
blank,
|
|
266
|
+
removed,
|
|
267
|
+
subagentMode,
|
|
268
|
+
placeholderFound: target !== null && target !== undefined,
|
|
269
|
+
afterContent: after.slice(0, 80),
|
|
270
|
+
styleInDom: document.querySelector('style[data-dsh-composer-predict]') !== null,
|
|
271
|
+
})
|
|
272
|
+
}, [anchorRef, blank, draft, generation, key, paints, phase, removed, sessionId, subagentMode, suggestion, surface])
|
|
273
|
+
|
|
274
|
+
const accept = React.useCallback(() => {
|
|
275
|
+
if (typeof suggestion !== 'string' || suggestion === '') return
|
|
276
|
+
try {
|
|
277
|
+
inputActions.setDraft(suggestion)
|
|
278
|
+
} catch {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
}, [inputActions, suggestion])
|
|
282
|
+
|
|
283
|
+
React.useEffect(() => {
|
|
284
|
+
if (!paints) return undefined
|
|
285
|
+
const onKeyDown = (event) => {
|
|
286
|
+
if (event.key !== 'Tab' || event.isComposing) return
|
|
287
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
|
288
|
+
if (event.defaultPrevented) return
|
|
289
|
+
const dock = anchorRef.current?.closest('[data-composer-dock]')
|
|
290
|
+
if (!dock) return
|
|
291
|
+
const active = document.activeElement
|
|
292
|
+
if (!active || active.isContentEditable !== true) return
|
|
293
|
+
if (!dock.parentElement?.contains(active)) return
|
|
294
|
+
event.preventDefault()
|
|
295
|
+
event.stopPropagation()
|
|
296
|
+
accept()
|
|
297
|
+
}
|
|
298
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
299
|
+
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
300
|
+
}, [accept, paints])
|
|
301
|
+
|
|
302
|
+
if (!paints) return null
|
|
303
|
+
|
|
304
|
+
if (surface === 'row') {
|
|
305
|
+
return h(
|
|
306
|
+
'div',
|
|
307
|
+
{ style: ROW.row, ref: anchorRef },
|
|
308
|
+
h(
|
|
309
|
+
'button',
|
|
310
|
+
{
|
|
311
|
+
type: 'button',
|
|
312
|
+
style: ROW.hint,
|
|
313
|
+
title: `${suggestion} — Tab`,
|
|
314
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
315
|
+
onClick: accept,
|
|
316
|
+
},
|
|
317
|
+
h('span', { style: ROW.text }, suggestion),
|
|
318
|
+
),
|
|
319
|
+
h(
|
|
320
|
+
'button',
|
|
321
|
+
{
|
|
322
|
+
type: 'button',
|
|
323
|
+
style: ROW.dismiss,
|
|
324
|
+
title: '×',
|
|
325
|
+
'aria-label': '×',
|
|
326
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
327
|
+
onClick: () => setEntry({ key, text: null }),
|
|
328
|
+
},
|
|
329
|
+
'×',
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return h('style', { ref: anchorRef, 'data-dsh-composer-predict': true }, ghostCss(suggestion))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
inject: ['slots'],
|
|
339
|
+
apply(ctx) {
|
|
340
|
+
ctx.slots.inject('conversation.composer.dock', () =>
|
|
341
|
+
ctx.slots.register(
|
|
342
|
+
{
|
|
343
|
+
name: 'conversation.composer.dock',
|
|
344
|
+
id: 'composer-predict',
|
|
345
|
+
order: 0,
|
|
346
|
+
},
|
|
347
|
+
PlaceholderPainter,
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Composer prediction">
|
|
2
|
+
<rect x="1" y="1" width="22" height="22" rx="6" fill="#2f6fed"/>
|
|
3
|
+
<path d="M5.6 7.4 9.3 12l-3.7 4.6" fill="none" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
<path d="M10.6 16.6h7.6" fill="none" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<path d="M17.2 5.2l.72 1.86 1.86.72-1.86.72-.72 1.86-.72-1.86-1.86-.72 1.86-.72z" fill="#ffffff" opacity="0.9"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the composer-prediction bundle.
|
|
3
|
+
*
|
|
4
|
+
* One authenticated Fetch route reads the tail of the Session log and asks the model
|
|
5
|
+
* that Session already uses for the user's most likely next instruction. The Client
|
|
6
|
+
* half only renders the answer and turns it into an editable draft.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { appendFile } from 'node:fs/promises'
|
|
10
|
+
|
|
11
|
+
export const name = 'composer-predict'
|
|
12
|
+
|
|
13
|
+
export const inject = ['connection']
|
|
14
|
+
|
|
15
|
+
/** Route the browser addresses document-relatively. */
|
|
16
|
+
const ROUTE_PATH = '/api/composer.predict'
|
|
17
|
+
|
|
18
|
+
/** Client diagnostics route; only answers while the diagnostic log is enabled. */
|
|
19
|
+
const REPORT_PATH = '/api/composer.report'
|
|
20
|
+
|
|
21
|
+
/** Where the diagnostic log lands, when enabled: next to this module, in the checkout. */
|
|
22
|
+
const REPORT_FILE = new URL('./.diagnostics.jsonl', import.meta.url)
|
|
23
|
+
|
|
24
|
+
/** Per-Session answer cache size; a Session's `seq` invalidates its own entry. */
|
|
25
|
+
const CACHE_LIMIT = 64
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
enabled: true,
|
|
29
|
+
maxContextMessages: 8,
|
|
30
|
+
maxMessageChars: 600,
|
|
31
|
+
maxSuggestionChars: 32,
|
|
32
|
+
maxTokens: 200,
|
|
33
|
+
timeoutMs: 20000,
|
|
34
|
+
cacheTtlMs: 60000,
|
|
35
|
+
provider: '',
|
|
36
|
+
model: '',
|
|
37
|
+
diagnostics: false,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register the prediction route and the client diagnostic route.
|
|
42
|
+
* @param ctx - Host context carrying the Web connection.
|
|
43
|
+
* @param config - row configuration from the bundle patch.
|
|
44
|
+
*/
|
|
45
|
+
export function apply(ctx, config = {}) {
|
|
46
|
+
const settings = resolveSettings(config)
|
|
47
|
+
const cache = new Map()
|
|
48
|
+
ctx.effect(() => {
|
|
49
|
+
const connection = Reflect.get(ctx, 'connection')
|
|
50
|
+
const disposers = [
|
|
51
|
+
connection.fetch.register({
|
|
52
|
+
path: ROUTE_PATH,
|
|
53
|
+
methods: ['POST'],
|
|
54
|
+
requestBody: 'buffered',
|
|
55
|
+
fetch: (request) => handle(ctx, request, settings, cache),
|
|
56
|
+
}),
|
|
57
|
+
connection.fetch.register({
|
|
58
|
+
path: REPORT_PATH,
|
|
59
|
+
methods: ['POST'],
|
|
60
|
+
requestBody: 'buffered',
|
|
61
|
+
fetch: (request) => report(request, settings),
|
|
62
|
+
}),
|
|
63
|
+
]
|
|
64
|
+
return () => {
|
|
65
|
+
for (const dispose of disposers) Promise.resolve(dispose()).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
}, 'composer-predict: routes')
|
|
68
|
+
if (settings.diagnostics === true) {
|
|
69
|
+
const line = `${JSON.stringify({ time: new Date().toISOString(), event: 'host-apply', settings })}\n`
|
|
70
|
+
void appendFile(REPORT_FILE, line, 'utf8').catch(() => {})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Append one diagnostic line when `diagnostics` is on. Silent otherwise.
|
|
76
|
+
* @param settings - resolved plugin settings.
|
|
77
|
+
* @param row - fields to record.
|
|
78
|
+
*/
|
|
79
|
+
function trace(settings, row) {
|
|
80
|
+
if (settings.diagnostics !== true) return
|
|
81
|
+
const line = `${JSON.stringify({ time: new Date().toISOString(), ...row })}\n`
|
|
82
|
+
void appendFile(REPORT_FILE, line, 'utf8').catch(() => {})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Append one client diagnostic line.
|
|
87
|
+
* @param request - buffered Fetch request carrying the client snapshot.
|
|
88
|
+
* @param settings - resolved plugin settings.
|
|
89
|
+
* @returns an empty OK response.
|
|
90
|
+
*/
|
|
91
|
+
async function report(request, settings) {
|
|
92
|
+
if (settings.diagnostics !== true) return new Response(null, { status: 204 })
|
|
93
|
+
let body
|
|
94
|
+
try {
|
|
95
|
+
body = await request.json()
|
|
96
|
+
} catch {
|
|
97
|
+
return json(400, { error: 'invalid-body' })
|
|
98
|
+
}
|
|
99
|
+
const line = `${JSON.stringify({ time: new Date().toISOString(), ...body })}\n`
|
|
100
|
+
try {
|
|
101
|
+
await appendFile(REPORT_FILE, line, 'utf8')
|
|
102
|
+
} catch {
|
|
103
|
+
return json(500, { error: 'write-failed' })
|
|
104
|
+
}
|
|
105
|
+
return new Response(null, { status: 204 })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Answer one prediction request.
|
|
110
|
+
* @param ctx - Host context.
|
|
111
|
+
* @param request - buffered Fetch request whose body is `{ sessionId }`.
|
|
112
|
+
* @param settings - resolved plugin settings.
|
|
113
|
+
* @param cache - per-Session answer cache.
|
|
114
|
+
* @returns a JSON response; transport failures never surface as 500.
|
|
115
|
+
*/
|
|
116
|
+
async function handle(ctx, request, settings, cache) {
|
|
117
|
+
if (!settings.enabled) return json(503, { suggestion: null, error: 'disabled' })
|
|
118
|
+
|
|
119
|
+
let payload
|
|
120
|
+
try {
|
|
121
|
+
payload = await request.json()
|
|
122
|
+
} catch {
|
|
123
|
+
return json(400, { suggestion: null, error: 'invalid-body' })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
|
|
127
|
+
if (sessionId === '') return json(400, { suggestion: null, error: 'missing-session' })
|
|
128
|
+
|
|
129
|
+
const session = safe(() => ctx.get('sessions')?.get?.(sessionId))
|
|
130
|
+
if (!session) {
|
|
131
|
+
trace(settings, { event: 'predict-request', sessionId, found: false })
|
|
132
|
+
return json(404, { suggestion: null, error: 'session-not-found' })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const seq = Number(session.seq) || 0
|
|
136
|
+
const cached = cache.get(sessionId)
|
|
137
|
+
if (cached !== undefined && cached.seq === seq && Date.now() - cached.time < settings.cacheTtlMs) {
|
|
138
|
+
return json(200, { suggestion: cached.suggestion })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const llm = safe(() => ctx.get('llm'))
|
|
142
|
+
if (!llm) return json(503, { suggestion: null, error: 'llm-unavailable' })
|
|
143
|
+
|
|
144
|
+
const route = resolveRoute(ctx, session, settings)
|
|
145
|
+
if (!route) return json(503, { suggestion: null, error: 'model-unavailable' })
|
|
146
|
+
|
|
147
|
+
const transcript = buildTranscript(session, settings)
|
|
148
|
+
trace(settings, { event: 'predict-request', sessionId, found: true, transcriptLength: transcript.length })
|
|
149
|
+
if (transcript === '') return json(200, { suggestion: null })
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), settings.timeoutMs)
|
|
153
|
+
const abort = () => controller.abort()
|
|
154
|
+
request.signal?.addEventListener('abort', abort)
|
|
155
|
+
try {
|
|
156
|
+
let text = ''
|
|
157
|
+
const stream = llm.stream({
|
|
158
|
+
provider: route.provider,
|
|
159
|
+
model: route.model,
|
|
160
|
+
system: systemPrompt(settings),
|
|
161
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: transcript }] }],
|
|
162
|
+
maxTokens: settings.maxTokens,
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
})
|
|
165
|
+
for await (const chunk of stream) {
|
|
166
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
167
|
+
else if (chunk?.type === 'finish' && chunk.reason?.kind !== 'stop') break
|
|
168
|
+
}
|
|
169
|
+
const suggestion = cleanSuggestion(text, settings)
|
|
170
|
+
trace(settings, { event: 'predict-done', sessionId, suggestion, raw: text.slice(0, 120) })
|
|
171
|
+
remember(cache, sessionId, { seq, suggestion, time: Date.now() })
|
|
172
|
+
return json(200, { suggestion })
|
|
173
|
+
} catch (error) {
|
|
174
|
+
trace(settings, { event: 'predict-error', sessionId, message: messageOf(error) })
|
|
175
|
+
return json(200, { suggestion: null, error: messageOf(error) })
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
request.signal?.removeEventListener('abort', abort)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Keep the cache bounded, most recently used last.
|
|
184
|
+
* @param cache - session cache.
|
|
185
|
+
* @param key - Session id.
|
|
186
|
+
* @param entry - cached answer.
|
|
187
|
+
*/
|
|
188
|
+
function remember(cache, key, entry) {
|
|
189
|
+
cache.delete(key)
|
|
190
|
+
cache.set(key, entry)
|
|
191
|
+
while (cache.size > CACHE_LIMIT) {
|
|
192
|
+
const oldest = cache.keys().next()
|
|
193
|
+
if (oldest.done === true) break
|
|
194
|
+
cache.delete(oldest.value)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve the model route: explicit config, then the Session's frozen request head,
|
|
200
|
+
* then its last request context, then the process default.
|
|
201
|
+
* @param ctx - Host context.
|
|
202
|
+
* @param session - live Session.
|
|
203
|
+
* @param settings - resolved plugin settings.
|
|
204
|
+
* @returns a provider/model pair, or undefined when none is configured.
|
|
205
|
+
*/
|
|
206
|
+
function resolveRoute(ctx, session, settings) {
|
|
207
|
+
if (settings.provider !== '' && settings.model !== '') {
|
|
208
|
+
return { provider: settings.provider, model: settings.model }
|
|
209
|
+
}
|
|
210
|
+
const config = safe(() => session.requestHeader()?.config)
|
|
211
|
+
if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
|
|
212
|
+
return { provider: config.provider, model: config.model }
|
|
213
|
+
}
|
|
214
|
+
const context = safe(() => session.requestContext())
|
|
215
|
+
if (typeof context?.provider === 'string' && typeof context?.model === 'string') {
|
|
216
|
+
return { provider: context.provider, model: context.model }
|
|
217
|
+
}
|
|
218
|
+
const selection = safe(() => ctx.get('agentDefaultModel')?.currentSelection?.())
|
|
219
|
+
if (typeof selection?.provider === 'string' && typeof selection?.model === 'string') {
|
|
220
|
+
return { provider: selection.provider, model: selection.model }
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Render the conversation tail as role-labelled lines.
|
|
227
|
+
* @param session - live Session.
|
|
228
|
+
* @param settings - resolved plugin settings.
|
|
229
|
+
* @returns the prompt body, empty when the Session carries no conversation.
|
|
230
|
+
*/
|
|
231
|
+
function buildTranscript(session, settings) {
|
|
232
|
+
const messages = safe(() => session.deriveMessages()) ?? []
|
|
233
|
+
const lines = []
|
|
234
|
+
for (const message of messages) {
|
|
235
|
+
if (message?.role !== 'user' && message?.role !== 'assistant') continue
|
|
236
|
+
const blocks = Array.isArray(message.content) ? message.content : []
|
|
237
|
+
const text = blocks
|
|
238
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
239
|
+
.map((block) => block.text)
|
|
240
|
+
.join('\n')
|
|
241
|
+
.trim()
|
|
242
|
+
const tools = blocks.filter((block) => block?.type === 'tool-call' && typeof block.name === 'string').map((block) => block.name)
|
|
243
|
+
if (text === '' && tools.length === 0) continue
|
|
244
|
+
const label = message.role === 'user' ? '用户' : '助手'
|
|
245
|
+
const toolNote = tools.length === 0 ? '' : `(调用工具:${[...new Set(tools)].join('、')})`
|
|
246
|
+
lines.push(`${label}: ${clip(text, settings.maxMessageChars)}${toolNote}`)
|
|
247
|
+
}
|
|
248
|
+
if (lines.length === 0) return ''
|
|
249
|
+
const tail = lines.slice(-Math.max(1, settings.maxContextMessages))
|
|
250
|
+
const cwd = safe(() => session.header?.cwd)
|
|
251
|
+
const where = typeof cwd === 'string' && cwd !== '' ? `工作目录:${cwd}\n\n` : ''
|
|
252
|
+
return `${where}对话尾部:\n${tail.join('\n')}\n\n请只输出用户的下一条指令;没有就什么都不输出。`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The instruction block for one prediction call.
|
|
257
|
+
* @param settings - resolved plugin settings.
|
|
258
|
+
* @returns the system prompt.
|
|
259
|
+
*/
|
|
260
|
+
function systemPrompt(settings) {
|
|
261
|
+
const limit = settings.maxSuggestionChars
|
|
262
|
+
return [
|
|
263
|
+
'你是一个编码智能体的「下一步指令预测器」。',
|
|
264
|
+
'阅读给出的对话尾部,预测用户此刻最可能直接发送的下一条指令。',
|
|
265
|
+
'',
|
|
266
|
+
'规则:',
|
|
267
|
+
'- 使用对话本身的语言(对话以中文为主就用中文,以英文为主就用英文)。',
|
|
268
|
+
`- 只输出一条指令:具体、可执行,不超过 ${limit} 个汉字或 ${limit} 个英文单词。`,
|
|
269
|
+
'- 只输出指令本身:不要引号、不要编号、不要句末句号、不要解释、不要换行。',
|
|
270
|
+
'- 给出紧接着的下一步:运行或验证、修复报错、补充测试、继续实现、调整样式、打开结果。',
|
|
271
|
+
'- 不要复述用户上一句,也不要给出与当前任务无关的通用建议。',
|
|
272
|
+
'- 只要对话里有具体的任务上下文,就给出你认为最可能的一条指令。',
|
|
273
|
+
'- 对话里没有具体任务(例如只有问候、闲聊、或话题刚结束)时,什么都不要输出,不要编造任务。',
|
|
274
|
+
'- 不要输出 NONE、无、N/A 之类的占位字样。',
|
|
275
|
+
].join('\n')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Narrow the model reply to one editable instruction.
|
|
280
|
+
* @param text - accumulated model text.
|
|
281
|
+
* @param settings - resolved plugin settings.
|
|
282
|
+
* @returns the instruction, or null when the model declined.
|
|
283
|
+
*/
|
|
284
|
+
function cleanSuggestion(text, settings) {
|
|
285
|
+
if (typeof text !== 'string') return null
|
|
286
|
+
const line = text
|
|
287
|
+
.split('\n')
|
|
288
|
+
.map((part) => part.trim())
|
|
289
|
+
.find((part) => part !== '')
|
|
290
|
+
if (line === undefined) return null
|
|
291
|
+
let value = line
|
|
292
|
+
.replace(/^[-*•\d.、))\s]+/u, '')
|
|
293
|
+
.replace(/^["'“”‘’`「」《》]+|["'“”‘’`「」《》]+$/gu, '')
|
|
294
|
+
.trim()
|
|
295
|
+
if (value === '') return null
|
|
296
|
+
/* A model told to stay silent sometimes writes "nothing" as words instead. */
|
|
297
|
+
if (/^(none|n\/a|无|没有|无法预测|什么都不输出|不输出|空回复|空)$/iu.test(value)) return null
|
|
298
|
+
const cap = Math.max(8, settings.maxSuggestionChars * 3)
|
|
299
|
+
if (value.length > cap) value = value.slice(0, cap)
|
|
300
|
+
return value
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Keep the head and tail of one long message.
|
|
305
|
+
* @param text - message text.
|
|
306
|
+
* @param limit - maximum characters.
|
|
307
|
+
* @returns the clipped text.
|
|
308
|
+
*/
|
|
309
|
+
function clip(text, limit) {
|
|
310
|
+
if (typeof text !== 'string') return ''
|
|
311
|
+
if (limit <= 0 || text.length <= limit) return text
|
|
312
|
+
const head = Math.max(1, Math.floor(limit * 0.7))
|
|
313
|
+
const tail = Math.max(1, Math.floor(limit * 0.25))
|
|
314
|
+
return `${text.slice(0, head)} … ${text.slice(-tail)}`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Read a value without letting an unavailable capability break the route.
|
|
319
|
+
* @param read - thunk returning the value.
|
|
320
|
+
* @returns the value or undefined.
|
|
321
|
+
*/
|
|
322
|
+
function safe(read) {
|
|
323
|
+
try {
|
|
324
|
+
return read()
|
|
325
|
+
} catch {
|
|
326
|
+
return undefined
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Describe an error for the Client, which only logs it.
|
|
332
|
+
* @param error - thrown value.
|
|
333
|
+
* @returns a short message.
|
|
334
|
+
*/
|
|
335
|
+
function messageOf(error) {
|
|
336
|
+
if (error instanceof Error) return error.message
|
|
337
|
+
return String(error)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build one JSON response.
|
|
342
|
+
* @param status - HTTP status.
|
|
343
|
+
* @param body - JSON-serializable body.
|
|
344
|
+
* @returns the response.
|
|
345
|
+
*/
|
|
346
|
+
function json(status, body) {
|
|
347
|
+
return new Response(JSON.stringify(body), {
|
|
348
|
+
status,
|
|
349
|
+
headers: {
|
|
350
|
+
'content-type': 'application/json; charset=utf-8',
|
|
351
|
+
'cache-control': 'no-store',
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Merge row configuration over the defaults.
|
|
358
|
+
* @param config - row configuration.
|
|
359
|
+
* @returns resolved settings.
|
|
360
|
+
*/
|
|
361
|
+
function resolveSettings(config) {
|
|
362
|
+
const merged = { ...DEFAULTS }
|
|
363
|
+
for (const [key, value] of Object.entries(config ?? {})) {
|
|
364
|
+
if (!(key in DEFAULTS)) continue
|
|
365
|
+
if (typeof DEFAULTS[key] === 'number') {
|
|
366
|
+
const number = Number(value)
|
|
367
|
+
if (Number.isFinite(number) && number >= 0) merged[key] = number
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
if (typeof DEFAULTS[key] === 'boolean') {
|
|
371
|
+
merged[key] = value === true || value === 'true'
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (typeof value === 'string') merged[key] = value
|
|
375
|
+
}
|
|
376
|
+
return merged
|
|
377
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-predict",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Predicts your next instruction from the current conversation and shows it as the composer placeholder; press Tab to turn it into an editable draft.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek",
|
|
9
|
+
"harness",
|
|
10
|
+
"composer",
|
|
11
|
+
"prediction",
|
|
12
|
+
"autocomplete"
|
|
13
|
+
],
|
|
14
|
+
"author": "Dogtiti",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/Dogtiti/dsh-predict#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/Dogtiti/dsh-predict"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./index.js",
|
|
23
|
+
"./client": "./client.js",
|
|
24
|
+
"./locale/*.json": "./locale/*.json",
|
|
25
|
+
"./icon": "./icon.svg"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.js",
|
|
29
|
+
"client.js",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"locale/*.json",
|
|
32
|
+
"icon.svg"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"dsh": "^0.2.1-0"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dsh": {
|
|
41
|
+
"bundle": {
|
|
42
|
+
"patch": "./cordis.patch.yml"
|
|
43
|
+
},
|
|
44
|
+
"client": {
|
|
45
|
+
"platform": "web",
|
|
46
|
+
"immediately": true,
|
|
47
|
+
"inject": [
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/Dogtiti/dsh-predict/issues"
|
|
54
|
+
}
|
|
55
|
+
}
|