dsh-tree-task-flow 0.3.13 → 0.3.14
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/README.md +59 -14
- package/client.js +212 -10
- package/config.example.yml +12 -6
- package/cordis.patch.yml +5 -3
- package/lib/commands.js +45 -4
- package/lib/http.js +52 -6
- package/lib/index.js +23 -15
- package/lib/interrupt.js +18 -0
- package/lib/pause.js +40 -15
- package/lib/plan.js +48 -0
- package/lib/settings.js +53 -0
- package/lib/store.js +41 -0
- package/lib/tools.js +48 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ tree_task消息:接下来需要进行步骤:「步骤二」(s-4)。
|
|
|
105
105
|
但插件**不会拦住任何工具**——"这一层到底做完了没有"由模型判断,插件既不替它定死,
|
|
106
106
|
也不靠拒绝别的工具把它逼到墙角。
|
|
107
107
|
|
|
108
|
-
##
|
|
108
|
+
## 七个工具
|
|
109
109
|
|
|
110
110
|
**写树分两个工具,不是可以合并的重复。** `tree_task_create` 与 `tree_task_done` 是折叠的
|
|
111
111
|
两个边界:`create` 是"开工"那一下,`done` 是"收工"那一下。`create` 只在新建目标时调一次,
|
|
@@ -118,7 +118,11 @@ tree_task消息:接下来需要进行步骤:「步骤二」(s-4)。
|
|
|
118
118
|
| `tree_task_plan` | 给一个节点追加子节点:父节点是目标加的是任务,父节点是任务加的是子任务。**建新目标不归它管** |
|
|
119
119
|
| `tree_task_done` | 完成一个节点并提交它的结果,**`result` 必填**;返回的就是"下一步干什么",这次调用连同返回留在上下文里,被收走的只是它前面的执行过程 |
|
|
120
120
|
| `tree_task_update` | 修改一个节点的标题或说明 |
|
|
121
|
-
| `tree_task_drop` |
|
|
121
|
+
| `tree_task_drop` | 丢弃一个节点:留个记号说「这件事不做了」,节点**留在树里**可查(划掉)。丢弃目标会连同它下面的任务与子任务一起走,丢弃任务带上它的子任务 |
|
|
122
|
+
| `tree_task_delete` | **删除**一个节点:把它连同下面的任务或子任务从树里**摘掉**、不留痕,**不看状态**(已完成、已丢弃的都删得掉)。清理写错 / 建重 / 结果记错的内容用它 |
|
|
123
|
+
|
|
124
|
+
**丢与删是两件事**:丢弃回答"这件事还要不要做"(历史留着),删除是"这条内容本身就是错的"
|
|
125
|
+
(凭空摘掉)。一个节点做完之后再想让它消失,只能用删除。
|
|
122
126
|
|
|
123
127
|
层级固定三级,子任务是最低一级,不能再往下拆。
|
|
124
128
|
|
|
@@ -217,7 +221,9 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
217
221
|
当前节点的虚线圈会匀速转,已完成打勾、丢弃的划掉。
|
|
218
222
|
- 节点标题**不缩略**:有多长写多长,多了自动换行。
|
|
219
223
|
- 提交的结果**默认只占一行**,点一下摊开看全文。
|
|
220
|
-
-
|
|
224
|
+
- 节点上可以直接完成、**丢弃**(划掉,历史留着)、**删除**(垃圾桶,连子节点一起摘掉),
|
|
225
|
+
不用敲命令。删除对**任何状态**的节点都开着——已完成的那个也在,清理错内容正是它的用途。
|
|
226
|
+
- 进度只数**还在做的事**:被丢弃的节点分子分母都不算,所以丢过几个之后进度照样能满。
|
|
221
227
|
- 数据每 4 秒自动拉一次,模型在后台改计划时界面会自己跟上。
|
|
222
228
|
|
|
223
229
|
**没有计划的会话一个像素都不占**——读不到计划就返回 `null`,不会平白挤掉内置的
|
|
@@ -233,7 +239,8 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
233
239
|
|---|---|
|
|
234
240
|
| `/task-tree` | 查看本会话的任务树与检查点状态 |
|
|
235
241
|
| `/task-tree done <id> <结果>` | 提交某个节点的结果并完成它 |
|
|
236
|
-
| `/task-tree drop <id>` |
|
|
242
|
+
| `/task-tree drop <id>` | 丢弃某个节点:留着可查,标记为不做了 |
|
|
243
|
+
| `/task-tree delete <id>` | 删除某个节点:连子节点一起从树里摘掉,不看状态 |
|
|
237
244
|
| `/task-tree pause` | 暂停:正在跑的工具跑完就停住,不再往下推进 |
|
|
238
245
|
| `/task-tree resume` | 继续:从停住的地方接着跑,不往会话里插消息 |
|
|
239
246
|
| `/task-tree reset` | 清掉本会话的计划树 |
|
|
@@ -245,13 +252,34 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
245
252
|
| 字段 | 默认 | 说明 |
|
|
246
253
|
|---|---|---|
|
|
247
254
|
| `enabled` | `true` | 总开关;**装上即启用**,写 `false` 关掉 |
|
|
248
|
-
| `autoContinue` | `
|
|
255
|
+
| `autoContinue` | `false` | 模型停下时是否自动续行(见下节;**在 DSH 设置面板里能改**) |
|
|
249
256
|
| `maxAutoRounds` | `5` | 连续自动续行的轮次上限 |
|
|
250
|
-
| `interruptRecovery` | `true` |
|
|
257
|
+
| `interruptRecovery` | `true` | 意外中断后是否自动拉起(见下节;**在 DSH 设置面板里能改**) |
|
|
251
258
|
| `interruptDelaysMs` | `null` | 网络类中断的拉起时刻,`null` = 1/5/10/30 分钟 |
|
|
252
259
|
| `promptOrder` | `1800` | 工具说明段落的排序 |
|
|
253
260
|
| `rootDir` | `null` | 状态目录,默认 `$DSH_HOME/dsh-task-tree` |
|
|
254
261
|
|
|
262
|
+
## 设置
|
|
263
|
+
|
|
264
|
+
设置**在 DSH 自己的设置面板里**:打开侧边栏的设置,导航里有一页叫「树形任务流」
|
|
265
|
+
(跟「通用」「市场」并排),点开就是这两个开关。**改完立刻生效,不用重启。**
|
|
266
|
+
|
|
267
|
+
> 它不在任务条上。任务条只干两件事:看进度、按暂停 / 继续;偏好属于设置,就该待在设置里。
|
|
268
|
+
|
|
269
|
+
| 设置 | 默认 | 做什么 |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| 自动续行 | **关** | 模型自己停下、计划树还没跑完时,要不要推一条"接下来做 X"把它唤醒 |
|
|
272
|
+
| 工具出错自动拉起 | 开 | 这一轮不是正常结束(网络断了 / 模型输出坏了 / 工具结果没了)时,要不要自动重来 |
|
|
273
|
+
|
|
274
|
+
两项是**两件事**,别混:前者是"要不要往前推",后者是"要不要救"。默认这么定是因为
|
|
275
|
+
自动续行会在你没看着的时候自己往下跑,该由人来点;而中断拉起只在"这一轮根本不是正常
|
|
276
|
+
结束"时动手,正常停下它一步都不动。
|
|
277
|
+
|
|
278
|
+
设置写在状态目录的 `settings.json` 里(**不按会话分**,整个插件一份),重启后还在。
|
|
279
|
+
优先级是 **设置面板里改过的 > profile 里的 config > `DEFAULT_CONFIG`**:在面板里点过就以
|
|
280
|
+
面板为准,免得界面对不上 YAML。`enabled`、`maxAutoRounds`、`interruptDelaysMs` 这类
|
|
281
|
+
"装插件时就该定好"的项不在面板上,仍然只认 profile 配置。
|
|
282
|
+
|
|
255
283
|
## 意外中断自动拉起
|
|
256
284
|
|
|
257
285
|
上一节 `autoContinue` 管的是"模型自己正常停下"。这一节管的是**这一轮没能正常
|
|
@@ -287,6 +315,8 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
287
315
|
自己改道,本来也没中断这一轮。
|
|
288
316
|
- 只在**这个会话的计划树还没走完**时才动手。等待时刻写在状态目录的 `revive/` 下,
|
|
289
317
|
重启 `dsh web` 不会丢。
|
|
318
|
+
- **能在设置面板里关掉。** DSH 设置里的「树形任务流」那一页中,"工具出错自动拉起"就是
|
|
319
|
+
这个开关,改完立刻生效(关掉之后插件连 `revive/` 都不再写)。
|
|
290
320
|
|
|
291
321
|
## 暂停与继续
|
|
292
322
|
|
|
@@ -300,10 +330,23 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
300
330
|
接着请求,同样**零新增消息**。任务树的进度停在哪儿,就从哪儿接着跑。
|
|
301
331
|
|
|
302
332
|
**重启 `dsh web` 之后暂停依然有效。** "被按住"这件事写在状态目录的 `held/` 下,
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
333
|
+
重启后条目仍显示「已暂停」,点「继续」即可接着跑。
|
|
334
|
+
|
|
335
|
+
**点「继续」是无条件的。** 不管此刻闸门有没有挂在岔口上、会话是不是正忙,点一下,
|
|
336
|
+
"被按住"就当场解除——它是一句"接着跑",不是一次"此刻有没有闸门"的查询。区别只在
|
|
337
|
+
插件接下来能做什么:
|
|
338
|
+
|
|
339
|
+
- **闸门正挂在岔口上** → 直接放行那一步,**零新增消息**;
|
|
340
|
+
- **没有岔口可放**(进程重启过,或者你点的时候那个工具还在跑)→ 退一步给会话排一条
|
|
341
|
+
「恢复续行」的消息把它从 `idle` 推起来;会话正忙的话连推都不用推,它自己会在下一个
|
|
342
|
+
岔口接着跑;
|
|
343
|
+
- **那个会话还没被 DSH 加载成实时 agent** → 提示你先打开它或在其中发一条消息,
|
|
344
|
+
但"被按住"照样解除。
|
|
345
|
+
|
|
346
|
+
> 早先不是这样:"继续"只在闸门恰好挂着的那一瞬间才算数;没有闸门可放时又要求会话
|
|
347
|
+
> 正好停在 `idle`,忙就**直接拒绝、并且什么都不清**。于是"工具还在跑的时候点的那一下"
|
|
348
|
+
> 被静默吞掉,等闸门真挂上时已经没人在等它——会话就停在原地了。真回归见
|
|
349
|
+
> `verify-pause.mjs` 的场景 s-31。
|
|
307
350
|
|
|
308
351
|
闸门开在 agent 的 `agent/pre-step` 上——一个 step 走完、工具结果都已落盘、模型还没
|
|
309
352
|
发起下一次请求的那一刻。这是唯一同时满足"不打断工具"和"不留痕迹"的位置:再晚一点
|
|
@@ -320,8 +363,8 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
320
363
|
**模型自己停下、计划树却没跑完,插件就推一条"接下来要做 X"把它唤醒。** 这只是**兜底**:
|
|
321
364
|
`tree_task_done` 的返回文本里已经带了"请继续执行下一个子任务",模型通常会在同一轮里接着做。
|
|
322
365
|
|
|
323
|
-
`autoContinue`
|
|
324
|
-
|
|
366
|
+
`autoContinue` **默认关闭**——它会自己往下跑,所以默认由人来点。要开就在 DSH 设置面板的
|
|
367
|
+
「树形任务流」那一页里打开(**改完立刻生效**,不用重启),或者在 profile 里写 `autoContinue: true`。
|
|
325
368
|
|
|
326
369
|
开着也不会脱缰,有四道闸门:
|
|
327
370
|
|
|
@@ -344,7 +387,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
344
387
|
|
|
345
388
|
## 状态文件
|
|
346
389
|
|
|
347
|
-
全部在 `$DSH_HOME/dsh-task-tree/`
|
|
390
|
+
全部在 `$DSH_HOME/dsh-task-tree/` 下。除 `settings.json` 外都**以 `sessionId` 为键**:
|
|
348
391
|
|
|
349
392
|
| 文件 | 内容 |
|
|
350
393
|
|---|---|
|
|
@@ -352,6 +395,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
352
395
|
| `surfaces/<sessionId>.json` | 三层的进度游标 `{ key, cursor }`(只给界面看,不参与折叠) |
|
|
353
396
|
| `held/<sessionId>.json` | 暂停 / 停止状态(跨重启保留) |
|
|
354
397
|
| `revive/<sessionId>.json` | 意外中断的拉起时刻表(跨重启保留) |
|
|
398
|
+
| `settings.json` | 设置面板里改过的设置(**整个插件一份,不按会话分**) |
|
|
355
399
|
|
|
356
400
|
> 目录名沿用插件改名前的标识,为的是不让既有的计划文件失联;插件名与它无关。
|
|
357
401
|
|
|
@@ -384,6 +428,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
384
428
|
|
|
385
429
|
5. **改需求用「丢弃 + 追加」,不要重建整棵树。** 丢掉的节点留在树里可查,
|
|
386
430
|
它和完成的节点会一起被那一段折叠收走(通告只报条数,不区分完成还是丢弃)。
|
|
431
|
+
**内容写错了才用删除**——删除是摘掉,摘掉的东西查不回来,也没有任何通告替它留痕。
|
|
387
432
|
|
|
388
433
|
6. **暂停不留痕迹。** 暂停后模型看到的是"没有下一条请求",看不到任何"你被暂停了"
|
|
389
434
|
之类的提示;继续之后也不会有"继续"消息进上下文。想让模型知道中间发生过什么,
|
|
@@ -400,7 +445,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
400
445
|
| 不做 | 原因 |
|
|
401
446
|
|---|---|
|
|
402
447
|
| 资料 / 文件快照 / 记忆 | 上下文里值得留下的只有结果,其余靠模型自己重读 |
|
|
403
|
-
| 文件回退 / 撤销 | 改需求用「丢弃 +
|
|
448
|
+
| 文件回退 / 撤销 | 改需求用「丢弃 + 追加」;内容本身写错了才用删除摘掉,摘掉就找不回来 |
|
|
404
449
|
| 并行推进多个目标 | 结构上支持一个会话有多个目标(目标层就是兄弟列表),但同一时刻只沿一条活动路径走 |
|
|
405
450
|
| 把计划内容注入提示词 | 计划状态由 `tree_task_status` 现取,提示词里只放不随计划变化的说明 |
|
|
406
451
|
|
package/client.js
CHANGED
|
@@ -173,14 +173,23 @@ window.__ModuleLoader__.load({
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* 三层节点总数与已完成数,给单行条上的进度用。
|
|
178
|
+
*
|
|
179
|
+
* **丢弃的节点两边都不算。** 丢弃是"这件事我不做了",它不该压着进度——
|
|
180
|
+
* 早先 total 无条件 +1、done 只认 `done`,于是丢一个节点进度就永远差一格,
|
|
181
|
+
* 满不了。丢弃的证据在树里(划掉那一行),不需要分数再替它记一笔。
|
|
182
|
+
*/
|
|
177
183
|
function countNodes(goal) {
|
|
184
|
+
if (goal.status === "dropped") return { total: 0, done: 0 };
|
|
178
185
|
let total = 1;
|
|
179
186
|
let done = goal.status === "done" ? 1 : 0;
|
|
180
187
|
for (const task of goal.tasks || []) {
|
|
188
|
+
if (task.status === "dropped") continue;
|
|
181
189
|
total += 1;
|
|
182
190
|
if (task.status === "done") done += 1;
|
|
183
191
|
for (const step of task.steps || []) {
|
|
192
|
+
if (step.status === "dropped") continue;
|
|
184
193
|
total += 1;
|
|
185
194
|
if (step.status === "done") done += 1;
|
|
186
195
|
}
|
|
@@ -261,10 +270,21 @@ window.__ModuleLoader__.load({
|
|
|
261
270
|
"action.collapse": "收起",
|
|
262
271
|
"action.done": "完成…",
|
|
263
272
|
"action.drop": "丢弃",
|
|
273
|
+
"action.delete": "删除",
|
|
264
274
|
"action.submit": "提交结果",
|
|
265
275
|
"action.expandResult": "展开看全文",
|
|
266
276
|
"action.collapseResult": "收起",
|
|
267
277
|
"action.cancel": "取消",
|
|
278
|
+
"settings.nav": "树形任务流",
|
|
279
|
+
"settings.loading": "正在读设置…",
|
|
280
|
+
"settings.unavailable": "读不到设置:宿主那一半没在跑,或者接口不通。",
|
|
281
|
+
"settings.off": "插件在 profile 配置里被关掉了(enabled: false),这两个开关现在不起作用。",
|
|
282
|
+
"setting.note": "改完立刻生效,也会写进状态目录,重启 dsh web 之后还在。",
|
|
283
|
+
"setting.autoContinue": "任务没做完、模型自己停下时,自动把它叫起来接着跑",
|
|
284
|
+
"setting.autoContinueNote": "会往会话里推一条消息。默认关。",
|
|
285
|
+
"setting.interruptRecovery": "工具出错或网络断了,自动重试把会话拉起来",
|
|
286
|
+
"setting.interruptRecoveryNote":
|
|
287
|
+
"按 1 / 5 / 10 / 30 分钟各拉一次,一直拉不起来就停手。你按过停止的绝不拉起。",
|
|
268
288
|
"label.result": "结果",
|
|
269
289
|
"label.noResult": "(还没提交结果)",
|
|
270
290
|
"label.current": "当前",
|
|
@@ -274,7 +294,9 @@ window.__ModuleLoader__.load({
|
|
|
274
294
|
"写清这个节点产出了什么。它整段执行过程会被这条结果顶替,所以要能独立看懂。",
|
|
275
295
|
"hint.noChildren": "(还没有子节点 → 让模型调用 tree_task_plan)",
|
|
276
296
|
"confirm.reset": "确定要清空这个会话的计划树吗?",
|
|
277
|
-
"confirm.drop": "确定要丢弃「{title}
|
|
297
|
+
"confirm.drop": "确定要丢弃「{title}」吗?丢弃会把它标成「不做了」,节点留在树里可查。",
|
|
298
|
+
"confirm.delete":
|
|
299
|
+
"确定要删除「{title}」吗?它会连同下面的任务或子任务一起从树里消失,找不回来;已经提交的结果也会一起没掉。如果只是「这件事不做了」,请改用丢弃。",
|
|
278
300
|
};
|
|
279
301
|
|
|
280
302
|
const en = {
|
|
@@ -294,10 +316,21 @@ window.__ModuleLoader__.load({
|
|
|
294
316
|
"action.collapse": "Collapse",
|
|
295
317
|
"action.done": "Complete…",
|
|
296
318
|
"action.drop": "Drop",
|
|
319
|
+
"action.delete": "Delete",
|
|
297
320
|
"action.submit": "Submit result",
|
|
298
321
|
"action.expandResult": "Show the full result",
|
|
299
322
|
"action.collapseResult": "Collapse",
|
|
300
323
|
"action.cancel": "Cancel",
|
|
324
|
+
"settings.nav": "Task tree",
|
|
325
|
+
"settings.loading": "Reading settings…",
|
|
326
|
+
"settings.unavailable": "Settings are unreachable: the host half is not running, or the endpoint is blocked.",
|
|
327
|
+
"settings.off": "The plugin is switched off in the profile config (enabled: false), so these switches do nothing right now.",
|
|
328
|
+
"setting.note": "Takes effect right away and is written to the state directory, so it survives a restart.",
|
|
329
|
+
"setting.autoContinue": "When the plan is unfinished and the model stops, wake it up to carry on",
|
|
330
|
+
"setting.autoContinueNote": "This pushes a message into the session. Off by default.",
|
|
331
|
+
"setting.interruptRecovery": "After a tool error or a dropped connection, retry to get the session going",
|
|
332
|
+
"setting.interruptRecoveryNote":
|
|
333
|
+
"Retries at 1 / 5 / 10 / 30 minutes, then gives up. A session you stopped is never revived.",
|
|
301
334
|
"label.result": "Result",
|
|
302
335
|
"label.noResult": "(no result submitted yet)",
|
|
303
336
|
"label.current": "current",
|
|
@@ -307,7 +340,9 @@ window.__ModuleLoader__.load({
|
|
|
307
340
|
"State what this node produced. Its whole execution range is replaced by this result, so it must stand on its own.",
|
|
308
341
|
"hint.noChildren": "(no children yet → ask the model to call tree_task_plan)",
|
|
309
342
|
"confirm.reset": "Clear this session's plan tree?",
|
|
310
|
-
"confirm.drop": "Drop “{title}”?",
|
|
343
|
+
"confirm.drop": "Drop “{title}”? It is marked as not-to-do and stays in the tree.",
|
|
344
|
+
"confirm.delete":
|
|
345
|
+
"Delete “{title}”? It disappears from the tree together with its tasks or steps, and any submitted result goes with it. If you only mean “not doing this”, use Drop instead.",
|
|
311
346
|
};
|
|
312
347
|
|
|
313
348
|
// ---------------------------------------------------------------- 样式
|
|
@@ -393,6 +428,18 @@ window.__ModuleLoader__.load({
|
|
|
393
428
|
".dsh-ttf-btn:disabled:hover{background:var(--dsw-alias-bg-base)}",
|
|
394
429
|
".dsh-ttf-error{color:var(--dsw-alias-state-error-primary,#c00);font-size:12px;line-height:18px;white-space:pre-wrap}",
|
|
395
430
|
".dsh-ttf-hint{padding:4px 4px 6px 24px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
|
|
431
|
+
// 设置:两行开关,跟树共用同一张卡片。
|
|
432
|
+
".dsh-ttf-settingsPage{display:flex;flex-direction:column;gap:10px;padding:4px 2px;max-width:640px}",
|
|
433
|
+
".dsh-ttf-settings{display:flex;flex-direction:column;gap:8px;padding:4px 4px 8px}",
|
|
434
|
+
".dsh-ttf-setting{display:flex;align-items:flex-start;gap:8px}",
|
|
435
|
+
".dsh-ttf-switch{box-sizing:border-box;flex:none;position:relative;width:32px;height:18px;margin-top:1px;padding:0;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;background:var(--dsw-alias-bg-module-platform);cursor:pointer}",
|
|
436
|
+
".dsh-ttf-switch:disabled{opacity:.4;cursor:default}",
|
|
437
|
+
".dsh-ttf-switchOn{border-color:var(--dsw-alias-state-business-primary,#4d6bfe);background:var(--dsw-alias-state-business-primary,#4d6bfe)}",
|
|
438
|
+
".dsh-ttf-switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:999px;background:#fff;transition:left .12s}",
|
|
439
|
+
".dsh-ttf-switchOn .dsh-ttf-switchKnob{left:15px}",
|
|
440
|
+
".dsh-ttf-settingText{display:flex;flex-direction:column;gap:2px;min-width:0}",
|
|
441
|
+
".dsh-ttf-settingName{color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
|
|
442
|
+
".dsh-ttf-settingNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
|
|
396
443
|
].join("");
|
|
397
444
|
|
|
398
445
|
if (
|
|
@@ -505,7 +552,7 @@ window.__ModuleLoader__.load({
|
|
|
505
552
|
* 一个节点:一行标题 + 可展开的详情。
|
|
506
553
|
*
|
|
507
554
|
* 三层共用同一个组件——它们的字段与动作本来就一样,差别只有缩进,
|
|
508
|
-
* 以及"
|
|
555
|
+
* 以及"这一层要不要给丢弃与删除按钮"(目标、任务、子任务都能丢、都能删)。
|
|
509
556
|
*/
|
|
510
557
|
function NodeRow({ node, depth, activeIds, openIds, checkpointId, canDrop, sessionId, reload, t }) {
|
|
511
558
|
const children = node.tasks || node.steps || [];
|
|
@@ -574,7 +621,9 @@ window.__ModuleLoader__.load({
|
|
|
574
621
|
: null,
|
|
575
622
|
canAct && canDrop
|
|
576
623
|
? h(IconButton, {
|
|
577
|
-
|
|
624
|
+
// 划掉 = 留个记号说"这件事不做了",和树里已丢弃节点的 × 标记是同一个意思。
|
|
625
|
+
// 垃圾桶留给真正的删除(见下一个按钮)。
|
|
626
|
+
iconBase: "IconCloseOutline",
|
|
578
627
|
label: t("action.drop"),
|
|
579
628
|
disabled: busy,
|
|
580
629
|
onClick: () => {
|
|
@@ -584,6 +633,20 @@ window.__ModuleLoader__.load({
|
|
|
584
633
|
},
|
|
585
634
|
})
|
|
586
635
|
: null,
|
|
636
|
+
// 删除对**任何状态**都开着:它就是用来清理写错/建重的内容的,
|
|
637
|
+
// 已完成的节点恰恰是最需要清掉的那种(结果记错了)。
|
|
638
|
+
canDrop
|
|
639
|
+
? h(IconButton, {
|
|
640
|
+
iconBase: "IconTrashOutline",
|
|
641
|
+
label: t("action.delete"),
|
|
642
|
+
disabled: busy,
|
|
643
|
+
onClick: () => {
|
|
644
|
+
if (window.confirm(t("confirm.delete", { title: node.title }))) {
|
|
645
|
+
act({ action: "delete", id: node.id });
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
})
|
|
649
|
+
: null,
|
|
587
650
|
expandable
|
|
588
651
|
? h(IconButton, {
|
|
589
652
|
iconBase: open ? "IconChevronDownOutline" : "IconChevronRightOutline",
|
|
@@ -650,6 +713,121 @@ window.__ModuleLoader__.load({
|
|
|
650
713
|
|
|
651
714
|
// ---------------------------------------------------------------- 条目
|
|
652
715
|
|
|
716
|
+
/**
|
|
717
|
+
* 设置里能改的开关。
|
|
718
|
+
*
|
|
719
|
+
* 键必须与 `lib/settings.js` 的 `SETTING_KEYS` **一模一样**:对不上时接口会
|
|
720
|
+
* 400 拒掉,而界面只会安静地什么都没发生,最难查。自检里有一条断言把两份
|
|
721
|
+
* 名单对起来(`verify-settings.mjs`)。
|
|
722
|
+
*
|
|
723
|
+
* 文案名就按 `setting.<键>` 拼,另配一条 `setting.<键>Note` 讲清楚它到底会做什么。
|
|
724
|
+
*/
|
|
725
|
+
const SETTING_ROWS = [
|
|
726
|
+
{ key: "autoContinue", note: "setting.autoContinueNote" },
|
|
727
|
+
{ key: "interruptRecovery", note: "setting.interruptRecoveryNote" },
|
|
728
|
+
];
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 设置面板里的那一页:两个开关。
|
|
732
|
+
*
|
|
733
|
+
* ★ 这一页挂在 **DSH 自己的设置面板**里(`settings.section` 槽),不在插件的
|
|
734
|
+
* 任务条上。任务条是"看进度、按暂停"的地方;偏好属于设置,就该待在设置里。
|
|
735
|
+
*
|
|
736
|
+
* 数据走宿主那两个**与会话无关**的接口:读 `/dsh-tree-task-flow/settings`,
|
|
737
|
+
* 写 `action: "setting"`。跟任务条不同,这里没有要盯的东西,所以不轮询:
|
|
738
|
+
* 挂载时读一次,改完拿返回值就地更新。
|
|
739
|
+
*/
|
|
740
|
+
function TaskTreeSettings() {
|
|
741
|
+
const t = useT();
|
|
742
|
+
const [state, setState] = useState({ loading: true, data: null });
|
|
743
|
+
const [busy, setBusy] = useState(false);
|
|
744
|
+
const [note, setNote] = useState("");
|
|
745
|
+
|
|
746
|
+
useEffect(() => {
|
|
747
|
+
let alive = true;
|
|
748
|
+
(async () => {
|
|
749
|
+
try {
|
|
750
|
+
const data = await api("/dsh-tree-task-flow/settings");
|
|
751
|
+
if (alive) setState({ loading: false, data });
|
|
752
|
+
} catch {
|
|
753
|
+
// 读不到就说读不到,别画一排骗人的开关。
|
|
754
|
+
if (alive) setState({ loading: false, data: null });
|
|
755
|
+
}
|
|
756
|
+
})();
|
|
757
|
+
return () => {
|
|
758
|
+
alive = false;
|
|
759
|
+
};
|
|
760
|
+
}, []);
|
|
761
|
+
|
|
762
|
+
if (state.loading) {
|
|
763
|
+
return h("div", { className: "dsh-ttf-settingsPage" }, h("div", { className: "dsh-ttf-settingNote" }, t("settings.loading")));
|
|
764
|
+
}
|
|
765
|
+
if (!state.data) {
|
|
766
|
+
return h(
|
|
767
|
+
"div",
|
|
768
|
+
{ className: "dsh-ttf-settingsPage" },
|
|
769
|
+
h("div", { className: "dsh-ttf-settingNote" }, t("settings.unavailable")),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const settings = state.data.settings ?? {};
|
|
774
|
+
|
|
775
|
+
async function toggle(key, value) {
|
|
776
|
+
setBusy(true);
|
|
777
|
+
setNote("");
|
|
778
|
+
try {
|
|
779
|
+
const result = await post({ action: "setting", key, value });
|
|
780
|
+
if (result && result.settings) {
|
|
781
|
+
setState({ loading: false, data: Object.assign({}, state.data, { settings: result.settings }) });
|
|
782
|
+
}
|
|
783
|
+
} catch (failure) {
|
|
784
|
+
setNote(String((failure && failure.message) || failure));
|
|
785
|
+
} finally {
|
|
786
|
+
setBusy(false);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return h(
|
|
791
|
+
"div",
|
|
792
|
+
{ className: "dsh-ttf-settingsPage" },
|
|
793
|
+
h("div", { className: "dsh-ttf-settingNote" }, t("setting.note")),
|
|
794
|
+
state.data.enabled === false
|
|
795
|
+
? h("div", { className: "dsh-ttf-note" }, t("settings.off"))
|
|
796
|
+
: null,
|
|
797
|
+
h(
|
|
798
|
+
"div",
|
|
799
|
+
{ className: "dsh-ttf-settings" },
|
|
800
|
+
SETTING_ROWS.map((row) => {
|
|
801
|
+
const on = settings[row.key] === true;
|
|
802
|
+
return h(
|
|
803
|
+
"div",
|
|
804
|
+
{ className: "dsh-ttf-setting", key: row.key },
|
|
805
|
+
h(
|
|
806
|
+
"button",
|
|
807
|
+
{
|
|
808
|
+
type: "button",
|
|
809
|
+
role: "switch",
|
|
810
|
+
"aria-checked": on,
|
|
811
|
+
"aria-label": t("setting." + row.key),
|
|
812
|
+
className: "dsh-ttf-switch" + (on ? " dsh-ttf-switchOn" : ""),
|
|
813
|
+
disabled: busy,
|
|
814
|
+
onClick: () => toggle(row.key, !on),
|
|
815
|
+
},
|
|
816
|
+
h("span", { className: "dsh-ttf-switchKnob" }),
|
|
817
|
+
),
|
|
818
|
+
h(
|
|
819
|
+
"div",
|
|
820
|
+
{ className: "dsh-ttf-settingText" },
|
|
821
|
+
h("span", { className: "dsh-ttf-settingName" }, t("setting." + row.key)),
|
|
822
|
+
h("span", { className: "dsh-ttf-settingNote" }, t(row.note)),
|
|
823
|
+
),
|
|
824
|
+
);
|
|
825
|
+
}),
|
|
826
|
+
),
|
|
827
|
+
note ? h("div", { className: "dsh-ttf-note" }, note) : null,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
653
831
|
/**
|
|
654
832
|
* 输入框上方的全宽条目。
|
|
655
833
|
*
|
|
@@ -762,11 +940,15 @@ window.__ModuleLoader__.load({
|
|
|
762
940
|
h("span", { className: "dsh-ttf-glyph", "aria-hidden": true }, listGlyph),
|
|
763
941
|
h("span", { className: "dsh-ttf-label" + (held ? " dsh-ttf-labelHeld" : "") }, stateLabel),
|
|
764
942
|
h("span", { className: "dsh-ttf-title", title: heading }, heading),
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
943
|
+
// 一个都算不上(整棵树都被丢弃了)就不报分数:0/0 比不显示更让人费解。
|
|
944
|
+
// 有 open 节点时 total 一定 ≥ 1,所以这里不会把"当前节点"那截弄丢。
|
|
945
|
+
stats.total > 0
|
|
946
|
+
? h(
|
|
947
|
+
"span",
|
|
948
|
+
{ className: "dsh-ttf-progress" },
|
|
949
|
+
stats.done + "/" + stats.total + (current ? " · " + current : ""),
|
|
950
|
+
)
|
|
951
|
+
: null,
|
|
770
952
|
h(
|
|
771
953
|
"div",
|
|
772
954
|
{ className: "dsh-ttf-actions" },
|
|
@@ -855,6 +1037,26 @@ window.__ModuleLoader__.load({
|
|
|
855
1037
|
),
|
|
856
1038
|
"dsh-tree-task-flow: conversation dock",
|
|
857
1039
|
);
|
|
1040
|
+
|
|
1041
|
+
// 设置面板里的那一页。用 `settings.section` 这个**列表槽**,所以它会作为
|
|
1042
|
+
// 一行出现在 DSH 设置面板的导航里(跟「通用」「市场」并排),点开就是
|
|
1043
|
+
// 那两个开关。槽没被组合出来时(比如没有设置面板的宿主)这里什么都不发生。
|
|
1044
|
+
ctx.effect(
|
|
1045
|
+
() =>
|
|
1046
|
+
ctx.slots.inject("settings.section", () =>
|
|
1047
|
+
ctx.slots.register(
|
|
1048
|
+
{
|
|
1049
|
+
name: "settings.section",
|
|
1050
|
+
id: ENTRY_ID,
|
|
1051
|
+
order: 60,
|
|
1052
|
+
label: () => translate("settings.nav"),
|
|
1053
|
+
locale: NS,
|
|
1054
|
+
},
|
|
1055
|
+
TaskTreeSettings,
|
|
1056
|
+
),
|
|
1057
|
+
),
|
|
1058
|
+
"dsh-tree-task-flow: settings section",
|
|
1059
|
+
);
|
|
858
1060
|
}
|
|
859
1061
|
|
|
860
1062
|
exports.name = name;
|
package/config.example.yml
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# 这个文件只是示例,插件不需要它——**安装即启用**,装完就能用。
|
|
2
2
|
#
|
|
3
|
-
#
|
|
3
|
+
# **两个开关平时不用改这里。** 打开 DSH 的设置面板,导航里「树形任务流」那一页点一下
|
|
4
|
+
# 就行——那是活的,改完立刻生效,而且**盖过这里写的值**。优先级是:
|
|
5
|
+
#
|
|
6
|
+
# 设置面板里改过的 > profile 里的 config > DEFAULT_CONFIG
|
|
7
|
+
#
|
|
8
|
+
# 这里留给"连设置面板也不想点"的场合(比如要一次性写进 profile 发给别人)。
|
|
4
9
|
# 想改成默认以外的东西时,把下面这段抄进 profile 自己的 cordis.patch.yml
|
|
5
10
|
# (通常是 $DSH_HOME/profiles/<名字>/cordis.patch.yml):
|
|
6
11
|
#
|
|
@@ -16,14 +21,15 @@
|
|
|
16
21
|
# 总开关。默认 true(装上即启用);写 false 就一句话关掉,
|
|
17
22
|
# 关掉后插件不注册工具、不加提示词、不折叠上下文、不续行,一个字节都不碰会话。
|
|
18
23
|
enabled: true
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
autoContinue:
|
|
24
|
+
# 模型停下时是否自动续行。**默认 false(不续)。**
|
|
25
|
+
# 它会在你没看着的时候自己往下跑,所以默认交给设置面板里的人来点——
|
|
26
|
+
# 设置面板里的那一页是个活的开关,改完立刻生效,不用重启。
|
|
27
|
+
autoContinue: false
|
|
23
28
|
# 连续自动续行的轮次上限,达到就停;只有真人发言才重置配额。
|
|
24
29
|
maxAutoRounds: 5
|
|
25
30
|
# 意外中断后是否自动拉起(见 README「意外中断自动拉起」)。默认 true:
|
|
26
|
-
#
|
|
31
|
+
# 它只在「这一轮不是正常结束」时动手,正常停下不受影响。同样能在设置面板里开关,
|
|
32
|
+
# 而且改完立刻生效——这一项是"要不要救",与上一项"要不要往前推"是两件事。
|
|
27
33
|
interruptRecovery: true
|
|
28
34
|
# 网络/API 类中断的拉起时刻,单位毫秒,默认 [1分钟, 5分钟, 10分钟, 30分钟];
|
|
29
35
|
# null 表示用内置默认值。工具执行中断不看这张表,它永远立即拉一次。
|
package/cordis.patch.yml
CHANGED
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
# 字段一次写全。可覆盖的字段见 DEFAULT_CONFIG:
|
|
13
13
|
#
|
|
14
14
|
# enabled 总开关 默认 true(写 false 关掉)
|
|
15
|
-
# autoContinue 是否允许自动续行 默认
|
|
15
|
+
# autoContinue 是否允许自动续行 默认 false(设置面板里能改)
|
|
16
16
|
# maxAutoRounds 连续自动续行轮次上限 默认 5
|
|
17
|
-
# interruptRecovery 意外中断是否自动拉起 默认 true
|
|
17
|
+
# interruptRecovery 意外中断是否自动拉起 默认 true(设置面板里能改)
|
|
18
18
|
# interruptDelaysMs 网络类中断拉起时刻 默认 null(= 1/5/10/30 分钟)
|
|
19
19
|
# promptOrder 工具说明段顺序 默认 1800
|
|
20
20
|
# rootDir 插件状态目录 默认 $DSH_HOME/dsh-task-tree
|
|
21
21
|
#
|
|
22
|
-
#
|
|
22
|
+
# 两个布尔开关(autoContinue / interruptRecovery)平时不用在这里改:
|
|
23
|
+
# **DSH 设置面板**的导航里有一页叫「树形任务流」,点一下就行——**改完立刻生效、
|
|
24
|
+
# 并且盖过这里写的值**(设置面板 > profile config > DEFAULT_CONFIG)。
|
|
23
25
|
- insert:
|
|
24
26
|
- id: task-tree
|
|
25
27
|
name: 'dsh-tree-task-flow'
|
package/lib/commands.js
CHANGED
|
@@ -8,13 +8,25 @@
|
|
|
8
8
|
* @module dsh-tree-task-flow/commands
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
activePath,
|
|
13
|
+
checkpointOf,
|
|
14
|
+
complete,
|
|
15
|
+
deleteNode,
|
|
16
|
+
dropNode,
|
|
17
|
+
findNode,
|
|
18
|
+
goalsOf,
|
|
19
|
+
levelLabel,
|
|
20
|
+
renderGoal,
|
|
21
|
+
renderTree,
|
|
22
|
+
} from "./plan.js";
|
|
12
23
|
|
|
13
24
|
/** 用法说明,status 和 help 都会带上。 */
|
|
14
25
|
const USAGE = [
|
|
15
26
|
"/task-tree 查看本会话的任务树与检查点状态",
|
|
16
27
|
"/task-tree done <id> <结果> 提交某个节点的结果并完成它",
|
|
17
|
-
"/task-tree drop <id>
|
|
28
|
+
"/task-tree drop <id> 丢弃某个节点:留着可查,标记为不做了",
|
|
29
|
+
"/task-tree delete <id> 删除某个节点:连子节点一起从树里摘掉,不看状态",
|
|
18
30
|
"/task-tree pause 暂停:当前工具跑完后停住,不再往下推进",
|
|
19
31
|
"/task-tree resume 继续:从停住的地方接着跑,不插消息",
|
|
20
32
|
"/task-tree reset 清掉本会话的计划树",
|
|
@@ -59,8 +71,9 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
|
|
|
59
71
|
|
|
60
72
|
commands.register({
|
|
61
73
|
name: "task-tree",
|
|
62
|
-
description:
|
|
63
|
-
|
|
74
|
+
description:
|
|
75
|
+
"查看与管理任务树:status / done <id> <结果> / drop <id> / delete <id> / pause / resume / reset",
|
|
76
|
+
input: { hint: "status | done <id> <结果> | drop <id> | delete <id> | pause | resume | reset" },
|
|
64
77
|
handler: (invocation) => {
|
|
65
78
|
const sessionId = invocation?.agent?.session?.id;
|
|
66
79
|
if (sessionId === undefined) {
|
|
@@ -151,6 +164,34 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
|
|
|
151
164
|
};
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
if (verb === "delete") {
|
|
168
|
+
const id = rest[0];
|
|
169
|
+
if (id === undefined) {
|
|
170
|
+
return { kind: "error", text: `用法:/task-tree delete <节点 id>\n\n${USAGE}` };
|
|
171
|
+
}
|
|
172
|
+
const plan = store.readPlan(sessionId);
|
|
173
|
+
if (plan === null) return { kind: "error", text: "本会话还没有计划。" };
|
|
174
|
+
// 摘掉之后就查不到"这次删的是哪个目标"了,先记一份。
|
|
175
|
+
const touched = findNode(plan, id);
|
|
176
|
+
const result = deleteNode(plan, id);
|
|
177
|
+
if (!result.ok) return { kind: "error", text: `没有删除:${result.reason}` };
|
|
178
|
+
store.writePlan(sessionId, plan);
|
|
179
|
+
|
|
180
|
+
const lines = [`已删除:${result.removed.join("、")}`, ""];
|
|
181
|
+
if (touched !== null && touched.level !== "goal") {
|
|
182
|
+
// 删的是任务或子任务:它所在的那个目标还在,只给它一个目标的树。
|
|
183
|
+
lines.push(renderGoal(touched.goal, plan, true));
|
|
184
|
+
} else {
|
|
185
|
+
const restGoal = goalsOf(plan).length;
|
|
186
|
+
lines.push(
|
|
187
|
+
restGoal === 0
|
|
188
|
+
? "计划树现在空了。"
|
|
189
|
+
: `这个会话还剩 ${restGoal} 个目标,要看全部用 /task-tree status。`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return { kind: "success", text: lines.join("\n") };
|
|
193
|
+
}
|
|
194
|
+
|
|
154
195
|
if (verb !== "status") {
|
|
155
196
|
return { kind: "error", text: `不认识的子命令:${verb}\n\n${USAGE}` };
|
|
156
197
|
}
|
package/lib/http.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* - **暂停状态**:条目上的暂停/继续按钮写的就是这个。暂停挂的是
|
|
15
15
|
* `agent/pre-step`(见 `pause.js`),不插消息、不改计划,所以它不属于计划树,
|
|
16
16
|
* 单独回传。
|
|
17
|
+
* - **设置**:面板上那两个开关(见 `settings.js`)。它跟会话无关,读的时候顺手带上,
|
|
18
|
+
* 改的时候写盘并**就地改活配置**,所以改完立刻生效,不用重启。
|
|
17
19
|
*
|
|
18
20
|
* 条目上的按钮都写计划树,所以写完要把**新的**活动路径、结果与检查点一并回传,
|
|
19
21
|
* 免得条目还要再发一次请求才知道自己按出了什么效果。
|
|
@@ -21,7 +23,8 @@
|
|
|
21
23
|
* @module dsh-tree-task-flow/http
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
|
-
import { activePath, checkpointOf, complete, dropNode, levelLabel } from "./plan.js";
|
|
26
|
+
import { activePath, checkpointOf, complete, deleteNode, dropNode, levelLabel } from "./plan.js";
|
|
27
|
+
import { SETTING_KEYS, settingsSnapshot } from "./settings.js";
|
|
25
28
|
|
|
26
29
|
/** 统一的 JSON 响应。 */
|
|
27
30
|
function sendJson(response, status, body) {
|
|
@@ -101,9 +104,11 @@ function afterWrite(plan) {
|
|
|
101
104
|
* 注册全部面板接口。
|
|
102
105
|
*
|
|
103
106
|
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
104
|
-
* @param options - `{ store, autoContinue, pause }
|
|
107
|
+
* @param options - `{ store, autoContinue, pause, cfg }`;插件被关闭时 `autoContinue`
|
|
108
|
+
* 与 `pause` 是 null。`cfg` 是那一份**活的对象**:读的时候用它报当前开关,
|
|
109
|
+
* 改的时候就地改它——各模块按事件现读,所以下一刻就生效。
|
|
105
110
|
*/
|
|
106
|
-
export function registerHttp(ctx, { store, autoContinue, pause = null }) {
|
|
111
|
+
export function registerHttp(ctx, { store, autoContinue, pause = null, cfg = null }) {
|
|
107
112
|
ctx.inject(["webServer"], (host) => {
|
|
108
113
|
host.effect(() => {
|
|
109
114
|
// 一棵树的全部信息:结构 + 每层结果 + 检查点 + 当前路径 + 三层折叠游标。
|
|
@@ -130,12 +135,31 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
|
|
|
130
135
|
},
|
|
131
136
|
stopped: autoContinue?.isStopped?.(sessionId) === true,
|
|
132
137
|
paused: pause?.isPaused?.(sessionId) === true,
|
|
138
|
+
settings: settingsSnapshot(cfg),
|
|
133
139
|
});
|
|
134
140
|
},
|
|
135
141
|
},
|
|
136
142
|
"dsh-tree-task-flow: tree",
|
|
137
143
|
);
|
|
138
144
|
|
|
145
|
+
// DSH 设置面板里那一页用的接口:**跟会话无关**,只回两个开关的当前值与总开关
|
|
146
|
+
// 状态。设置面板没有"当前会话"这个概念,所以它不能走 `/tree`。
|
|
147
|
+
host.webServer.register(
|
|
148
|
+
{
|
|
149
|
+
kind: "exact",
|
|
150
|
+
path: "/dsh-tree-task-flow/settings",
|
|
151
|
+
handler: async (request, response) => {
|
|
152
|
+
if (request.method !== "GET") return sendJson(response, 405, { ok: false, error: "只支持 GET" });
|
|
153
|
+
sendJson(response, 200, {
|
|
154
|
+
ok: true,
|
|
155
|
+
enabled: cfg?.enabled === true,
|
|
156
|
+
settings: settingsSnapshot(cfg),
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
"dsh-tree-task-flow: settings",
|
|
161
|
+
);
|
|
162
|
+
|
|
139
163
|
// 面板上的按钮都走这一个端点。
|
|
140
164
|
host.webServer.register(
|
|
141
165
|
{
|
|
@@ -161,6 +185,23 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
|
|
|
161
185
|
});
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
// 面板上的开关。跟会话无关,所以不读 sessionId。
|
|
189
|
+
//
|
|
190
|
+
// 两件事一起做:**写盘**(重启后还在)与**就地改活配置**(这一刻就生效)。
|
|
191
|
+
// 各模块都是"事件来了现读 cfg",所以改完不用重启,下一次事件就是新值。
|
|
192
|
+
if (action === "setting") {
|
|
193
|
+
const key = String(body.key ?? "");
|
|
194
|
+
if (!SETTING_KEYS.includes(key)) {
|
|
195
|
+
return sendJson(response, 400, { ok: false, error: `不认识的开关:${key}` });
|
|
196
|
+
}
|
|
197
|
+
if (typeof body.value !== "boolean") {
|
|
198
|
+
return sendJson(response, 400, { ok: false, error: "开关的值只能是 true 或 false" });
|
|
199
|
+
}
|
|
200
|
+
store.patchSettings({ [key]: body.value });
|
|
201
|
+
if (cfg !== null && typeof cfg === "object") cfg[key] = body.value;
|
|
202
|
+
return sendJson(response, 200, { ok: true, settings: settingsSnapshot(cfg) });
|
|
203
|
+
}
|
|
204
|
+
|
|
164
205
|
if (action === "reset") {
|
|
165
206
|
store.writePlan(sessionId, null);
|
|
166
207
|
// 计划没了,游标也必须跟着清:游标里记的是"这一层总结到哪儿了",
|
|
@@ -175,17 +216,22 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
|
|
|
175
216
|
return sendJson(response, 200, { ok: true, active: null, checkpoint: null, results: null });
|
|
176
217
|
}
|
|
177
218
|
|
|
178
|
-
if (action === "done" || action === "drop") {
|
|
219
|
+
if (action === "done" || action === "drop" || action === "delete") {
|
|
179
220
|
const plan = store.readPlan(sessionId);
|
|
180
221
|
if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话没有计划" });
|
|
181
222
|
const id = String(body.id ?? "");
|
|
182
223
|
// 完成必须带结果:插件要用它替换掉这个节点的执行过程。
|
|
183
|
-
const outcome =
|
|
224
|
+
const outcome =
|
|
225
|
+
action === "done"
|
|
226
|
+
? complete(plan, id, body.result)
|
|
227
|
+
: action === "drop"
|
|
228
|
+
? dropNode(plan, id)
|
|
229
|
+
: deleteNode(plan, id);
|
|
184
230
|
if (!outcome.ok) return sendJson(response, 200, { ok: false, error: outcome.reason });
|
|
185
231
|
store.writePlan(sessionId, plan);
|
|
186
232
|
return sendJson(response, 200, {
|
|
187
233
|
ok: true,
|
|
188
|
-
events: outcome.events ?? outcome.dropped ?? [],
|
|
234
|
+
events: outcome.events ?? outcome.dropped ?? outcome.removed ?? [],
|
|
189
235
|
...afterWrite(plan),
|
|
190
236
|
});
|
|
191
237
|
}
|
package/lib/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { registerInterruptRecovery } from "./interrupt.js";
|
|
|
25
25
|
import { registerPause } from "./pause.js";
|
|
26
26
|
import { DEFAULT_ORDER, registerPromptSection } from "./prompt.js";
|
|
27
27
|
import { registerRegion } from "./region.js";
|
|
28
|
+
import { pickSettings } from "./settings.js";
|
|
28
29
|
import { createStore, defaultRoot } from "./store.js";
|
|
29
30
|
import { registerTools } from "./tools.js";
|
|
30
31
|
|
|
@@ -55,17 +56,16 @@ export const DEFAULT_CONFIG = {
|
|
|
55
56
|
/**
|
|
56
57
|
* 是否允许在模型停下时自动续行。
|
|
57
58
|
*
|
|
58
|
-
* ★
|
|
59
|
-
*
|
|
60
|
-
* 再交给用户去配。
|
|
59
|
+
* ★ **默认关闭。** 它会趁你没说话的时候**往会话里推一条消息**、把模型再叫起来跑一轮,
|
|
60
|
+
* 是最需要人知情的一项。
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
62
|
+
* 这里曾经写死过一条规矩:"插件还没有配置界面,功能就不能默认关"——默认关等于这个
|
|
63
|
+
* 功能不存在,没人会去翻 YAML 把它打开。**那条规矩随着设置界面一起作废**:现在面板上
|
|
64
|
+
* 就能点开,默认值该按"会不会打扰人"来定,而不是按"能不能被发现"。
|
|
65
|
+
*
|
|
66
|
+
* 想打开:面板上的设置里点一下,或在 profile 里写 `autoContinue: true`。
|
|
67
67
|
*/
|
|
68
|
-
autoContinue:
|
|
68
|
+
autoContinue: false,
|
|
69
69
|
/** 连续自动续行的轮次上限,达到就停。 */
|
|
70
70
|
maxAutoRounds: 5,
|
|
71
71
|
/**
|
|
@@ -102,6 +102,13 @@ export function apply(ctx, config) {
|
|
|
102
102
|
const root = cfg.rootDir ?? defaultRoot();
|
|
103
103
|
const store = createStore(root);
|
|
104
104
|
|
|
105
|
+
// 面板上改过的开关盖在 profile 的 config 之上:**人在界面上的选择优先**。
|
|
106
|
+
// 不这么做的话,界面改完、profile 一改又被顶回去,人会以为开关坏了。
|
|
107
|
+
//
|
|
108
|
+
// `cfg` 是**活的对象**:面板改开关时就地改它,各模块拿的是同一个引用,
|
|
109
|
+
// 所以立刻生效,不用重启。这也是为什么各模块要按事件现读 `cfg`,不是开机读一次。
|
|
110
|
+
Object.assign(cfg, pickSettings(store.readSettings()));
|
|
111
|
+
|
|
105
112
|
// 被显式关掉时,一个字节都不碰会话:不注册工具、不加提示词段落、不折叠上下文、
|
|
106
113
|
// 不续行——只留一条命令,让人能确认"装了但被关掉了"以及怎么打开。
|
|
107
114
|
if (cfg.enabled !== true) {
|
|
@@ -109,7 +116,7 @@ export function apply(ctx, config) {
|
|
|
109
116
|
// 条目接口照挂:客户端条目与 enabled 无关,照样会来读。读不到计划它就返回
|
|
110
117
|
// null,一个像素都不占;而既有的计划文件仍然能看到。
|
|
111
118
|
// 只读插件自己的状态目录,不碰任何会话。
|
|
112
|
-
registerHttp(ctx, { store, autoContinue: null, pause: null });
|
|
119
|
+
registerHttp(ctx, { store, autoContinue: null, pause: null, cfg });
|
|
113
120
|
ctx.logger?.warn?.(
|
|
114
121
|
"dsh-tree-task-flow: 已按配置关闭(enabled=false)。要打开请在 profile 的 cordis.patch.yml 里把它设为 true。",
|
|
115
122
|
);
|
|
@@ -142,15 +149,16 @@ export function apply(ctx, config) {
|
|
|
142
149
|
// 意外中断的拉起:网络类按 1/5/10/30 分钟各一次,工具类(结果未知的合成错误)
|
|
143
150
|
// 立刻一次,再失败就收走那一次尝试再来一次。它与 autoContinue 并存:
|
|
144
151
|
// 那边管"模型正常收尾但计划没做完",这边管"模型没机会说完就断了"。
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
//
|
|
153
|
+
// ★ **无条件注册**:开关(`interruptRecovery`)在面板上随时可改,注册与否取决于
|
|
154
|
+
// 它就没法在运行中打开。真正的闸门放在模块内部——事件来了先问一句 `cfg`。
|
|
155
|
+
registerInterruptRecovery(ctx, { store, cfg, pause });
|
|
148
156
|
|
|
149
157
|
// /task-tree 命令:查看与管理任务树,也是"暂停 / 继续"的开关。
|
|
150
158
|
registerCommands(ctx, { store, cfg, pause, enabled: true });
|
|
151
159
|
|
|
152
|
-
// 任务树条目要用的 HTTP
|
|
153
|
-
registerHttp(ctx, { store, autoContinue, pause });
|
|
160
|
+
// 任务树条目要用的 HTTP 接口。`cfg` 传进去是为了让面板能读到并改掉那几个开关。
|
|
161
|
+
registerHttp(ctx, { store, autoContinue, pause, cfg });
|
|
154
162
|
|
|
155
163
|
ctx.logger?.info?.(`dsh-tree-task-flow: 已启用(状态目录 ${root})`);
|
|
156
164
|
}
|
package/lib/interrupt.js
CHANGED
|
@@ -198,6 +198,20 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
|
|
|
198
198
|
return active !== null && active.step !== null;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* 开关关着时整个模块都不动。
|
|
203
|
+
*
|
|
204
|
+
* 它是**面板上随时可改**的,所以每次事件现读 `cfg`,不能开机读一次就定死;
|
|
205
|
+
* 注册与否也不该取决于它(见 `lib/index.js` 里那句"无条件注册")。
|
|
206
|
+
*
|
|
207
|
+
* 判据写成"**显式 false 才算关**":这一项的默认值是开,缺字段的意思是"没配,
|
|
208
|
+
* 按默认走",不该被当成关掉——直接构造这个模块的调用方(自检、以后别的入口)
|
|
209
|
+
* 往往只给一个 `{}`。
|
|
210
|
+
*/
|
|
211
|
+
function off() {
|
|
212
|
+
return cfg?.interruptRecovery === false;
|
|
213
|
+
}
|
|
214
|
+
|
|
201
215
|
/** 现在能不能拉:暂停 / 停止 / 没计划 / 没加载 / 不空闲,一条不满足就不拉。 */
|
|
202
216
|
function blockedBy(sessionId) {
|
|
203
217
|
if (pause?.isPaused?.(sessionId) === true) return "paused";
|
|
@@ -220,6 +234,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
|
|
|
220
234
|
* @returns `{ ok, reason?, text? }`。
|
|
221
235
|
*/
|
|
222
236
|
function revive(sessionId, kind, code) {
|
|
237
|
+
if (off()) return { ok: false, reason: "disabled" };
|
|
223
238
|
const blocked = blockedBy(sessionId);
|
|
224
239
|
if (blocked !== null) return { ok: false, reason: blocked };
|
|
225
240
|
|
|
@@ -253,6 +268,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
|
|
|
253
268
|
* 一个不剩就把这条链标成用完。
|
|
254
269
|
*/
|
|
255
270
|
function scheduleNext(sessionId) {
|
|
271
|
+
if (off()) return;
|
|
256
272
|
clearTimer(sessionId);
|
|
257
273
|
const state = store.readRevive(sessionId);
|
|
258
274
|
if (state === null || state.class !== "network") return;
|
|
@@ -447,6 +463,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
|
|
|
447
463
|
// ---------------------------------------------------------------- 会话事件
|
|
448
464
|
|
|
449
465
|
ctx.on("session/event", (session, event) => {
|
|
466
|
+
if (off()) return;
|
|
450
467
|
const sessionId = session?.id;
|
|
451
468
|
if (sessionId === undefined || event === undefined) return;
|
|
452
469
|
|
|
@@ -495,6 +512,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
|
|
|
495
512
|
// ---------------------------------------------------------------- agent 状态
|
|
496
513
|
|
|
497
514
|
ctx.on("agent/status", (payload) => {
|
|
515
|
+
if (off()) return;
|
|
498
516
|
const sessionId = payload?.agent?.session?.id;
|
|
499
517
|
if (sessionId === undefined || payload?.status !== "idle") return;
|
|
500
518
|
|
package/lib/pause.js
CHANGED
|
@@ -35,6 +35,20 @@
|
|
|
35
35
|
* 唤醒要求该会话已被 DSH 加载成实时 agent(Web 界面打开这个会话就会触发按需
|
|
36
36
|
* resume)。拿不到 agent 时不做任何事,只回一句"先打开它,或在其中发一条消息"。
|
|
37
37
|
*
|
|
38
|
+
* ★ **点「继续」是无条件的。** "接着跑"是人的意思表示,不是"此刻有没有闸门挂着"
|
|
39
|
+
* 的一次查询。所以 `resume()` 的第一件事永远是**解除「被按住」**,然后才谈要不要
|
|
40
|
+
* 主动唤醒。
|
|
41
|
+
*
|
|
42
|
+
* 旧实现把这层意思押在了"此刻 `waiters` 里正好挂着一个"上:没有 waiter 就退到
|
|
43
|
+
* `wakeIdle()`,而它要求 agent 恰好 `idle`——会话正忙(工具还在跑、这一轮还没走到
|
|
44
|
+
* 岔口)时直接拒绝,并且**一个标记都不清**。于是那一次点击被静默吞掉:闸门在下一个
|
|
45
|
+
* 岔口照旧挂上,会话停住,人却以为自己已经点过了。实机就卡在这儿:
|
|
46
|
+
* session-c516e2cc 23:05:59 点了暂停,那个工具 23:07:03 才跑完,中间点的那次
|
|
47
|
+
* 「继续」白点了——等到闸门真挂上时,已经没人在等那一下点击。
|
|
48
|
+
*
|
|
49
|
+
* 唤醒失败也不再反过来把状态留下:留着只会让"我点过了它却还按着我"再来一次。
|
|
50
|
+
* 推不起来的原因照样如实回报(没加载 / 唤醒抛错 / 计划已收尾),人能据它决定下一步。
|
|
51
|
+
*
|
|
38
52
|
* @module dsh-tree-task-flow/pause
|
|
39
53
|
*/
|
|
40
54
|
|
|
@@ -71,6 +85,18 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
|
71
85
|
store?.patchHeld?.(sessionId, { paused: false });
|
|
72
86
|
}
|
|
73
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 真解除"被按住":暂停与停止两半一起清,内存、磁盘、自动续行压住的那一份都放开。
|
|
90
|
+
* 「继续」走的就是这一条,而且**无条件**(见模块头的 ★)。
|
|
91
|
+
*
|
|
92
|
+
* @param sessionId - 要放开的会话。
|
|
93
|
+
*/
|
|
94
|
+
function clearHeld(sessionId) {
|
|
95
|
+
paused.delete(sessionId);
|
|
96
|
+
store?.patchHeld?.(sessionId, { paused: false, stopped: false });
|
|
97
|
+
autoContinue?.resume?.(sessionId);
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/**
|
|
75
101
|
* 取这个会话的实时 agent。
|
|
76
102
|
*
|
|
@@ -138,7 +164,13 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
|
138
164
|
};
|
|
139
165
|
}
|
|
140
166
|
if (agent.status !== "idle") {
|
|
141
|
-
|
|
167
|
+
// 正忙不是失败:它自己会在下一个岔口接着跑,本来就不需要谁推它一把。
|
|
168
|
+
// 「被按住」已经由 `resume()` 无条件解除,所以这里什么都不用做。
|
|
169
|
+
return {
|
|
170
|
+
woke: false,
|
|
171
|
+
reason: "busy",
|
|
172
|
+
text: "这个会话正在跑,不用唤醒:被按住的状态已经解除,它会在下一个岔口接着往下跑。",
|
|
173
|
+
};
|
|
142
174
|
}
|
|
143
175
|
const plan = store?.readPlan?.(sessionId) ?? null;
|
|
144
176
|
const active = activePath(plan);
|
|
@@ -215,28 +247,21 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
|
215
247
|
return { released: false, woke: false, reason: "no-session", text: "拿不到当前会话。" };
|
|
216
248
|
}
|
|
217
249
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
store?.patchHeld?.(sessionId, { paused: false, stopped: false });
|
|
222
|
-
autoContinue?.resume?.(sessionId);
|
|
223
|
-
};
|
|
250
|
+
// ★ 先无条件解除"被按住",再谈唤醒。理由见模块头那段 ★:点「继续」是一个
|
|
251
|
+
// 意思表示,不该有条件;有条件就会制造"点过了却还按着"的静默卡死。
|
|
252
|
+
clearHeld(sessionId);
|
|
224
253
|
|
|
225
|
-
//
|
|
254
|
+
// 本进程里真挂着闸门:放行那一步——常规路径,**零新增消息**。
|
|
226
255
|
const pending = waiters.get(sessionId);
|
|
227
256
|
if (pending !== undefined && pending.size > 0) {
|
|
228
|
-
clearHeld();
|
|
229
257
|
for (const release of [...pending]) release();
|
|
230
258
|
return { released: true, woke: false, reason: "released", text: "停在岔口上的那一步已经放行。" };
|
|
231
259
|
}
|
|
232
260
|
|
|
233
|
-
// 没有挂着的 waiter
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// (会话没加载 / 正在跑 / 唤醒抛错)必须把状态留着:否则用户既没有「继续」
|
|
237
|
-
// 可再点一次,也看不到为什么没动,只剩一个停在原地的会话。
|
|
261
|
+
// 没有挂着的 waiter:本进程里没有东西可放行(进程重启过,或者这次暂停赶在
|
|
262
|
+
// 闸门挂上之前——那时候点的那一下,旧实现会被吞掉)。退一步,尽最大努力把
|
|
263
|
+
// idle 的会话推起来;推不起来也不改变"已经解除被按住"这个事实。
|
|
238
264
|
const outcome = wakeIdle(sessionId);
|
|
239
|
-
if (outcome.woke === true || outcome.reason === "finished") clearHeld();
|
|
240
265
|
return { released: false, ...outcome };
|
|
241
266
|
},
|
|
242
267
|
/**
|
package/lib/plan.js
CHANGED
|
@@ -355,6 +355,54 @@ export function dropNode(plan, id, now = Date.now()) {
|
|
|
355
355
|
};
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/**
|
|
359
|
+
* 删掉一个节点。**删除和丢弃是两件事,别混着用:**
|
|
360
|
+
*
|
|
361
|
+
* - **丢弃**回答"这件事还要不要做":节点留在树里划掉,将来还查得到"曾经有这么一件事,
|
|
362
|
+
* 后来决定不做了"。它不改写历史。
|
|
363
|
+
* - **删除**是**清理**:内容本身就是错的(建重了、写歪了、结果记在了错的地方),
|
|
364
|
+
* 留着只会误导后面的人。所以它把这个节点连同它的全部子孙从树里凭空摘掉,
|
|
365
|
+
* **不看状态**——已完成的照样删得掉,那正是"这条记录不该留在这儿"的情形。
|
|
366
|
+
*
|
|
367
|
+
* 摘完树可能是空的(最后一个目标被删掉):那时 `plan.goals` 是**空数组**,而不是
|
|
368
|
+
* 没有计划——计划文件照旧在盘上,渲染会给出"还没有计划"的样子。
|
|
369
|
+
*
|
|
370
|
+
* 活动路径与检查点在删完**重新算**,调用方不要拿删除前的那一份去用。
|
|
371
|
+
*
|
|
372
|
+
* @param plan - 计划树。
|
|
373
|
+
* @param id - 节点 id。
|
|
374
|
+
* @param now - 时间戳。
|
|
375
|
+
* @returns `{ ok, reason?, removed?, active?, checkpoint? }`。
|
|
376
|
+
*/
|
|
377
|
+
export function deleteNode(plan, id, now = Date.now()) {
|
|
378
|
+
const found = findNode(plan, id);
|
|
379
|
+
if (!found) return { ok: false, reason: `找不到节点 ${id}` };
|
|
380
|
+
|
|
381
|
+
// 先把要摘掉的那几个记下来——摘掉之后就找不着了。
|
|
382
|
+
const removed = [`${levelLabel(found.level)}「${found.node.title}」`];
|
|
383
|
+
if (found.level === "goal") {
|
|
384
|
+
for (const task of found.node.tasks ?? []) {
|
|
385
|
+
removed.push(`任务「${task.title}」`);
|
|
386
|
+
for (const step of task.steps ?? []) removed.push(`子任务「${step.title}」`);
|
|
387
|
+
}
|
|
388
|
+
plan.goals = goalsOf(plan).filter((goal) => goal.id !== id);
|
|
389
|
+
} else if (found.level === "task") {
|
|
390
|
+
for (const step of found.node.steps ?? []) removed.push(`子任务「${step.title}」`);
|
|
391
|
+
found.goal.tasks = (found.goal.tasks ?? []).filter((task) => task.id !== id);
|
|
392
|
+
} else {
|
|
393
|
+
found.task.steps = (found.task.steps ?? []).filter((step) => step.id !== id);
|
|
394
|
+
}
|
|
395
|
+
plan.updatedAt = now;
|
|
396
|
+
|
|
397
|
+
const checkpoint = checkpointOf(plan);
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
removed,
|
|
401
|
+
active: activePath(plan),
|
|
402
|
+
checkpoint: checkpoint === null ? null : { id: checkpoint.node.id, level: checkpoint.level },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
358
406
|
/**
|
|
359
407
|
* 改一个节点的标题或说明。
|
|
360
408
|
*
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 界面上的开关:**哪些键可以改**,以及怎么把它们并进配置。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件是为了让"可改的键"只有一份定义:`lib/index.js` 开机时用它把落盘的
|
|
5
|
+
* 选择并进配置,`lib/http.js` 收到面板的请求时用它校验键名。两处各写一份,
|
|
6
|
+
* 迟早会出现"界面能点、开机不认"或者反过来的错位。
|
|
7
|
+
*
|
|
8
|
+
* 优先级:**界面改过的(落盘)> profile 里的 config > DEFAULT_CONFIG**。
|
|
9
|
+
* 界面上改完还被 profile 顶回去,人会以为开关坏了。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-tree-task-flow/settings
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 面板可以改的键。
|
|
16
|
+
*
|
|
17
|
+
* 只收布尔开关:它们都是"要不要让插件自己动起来"这类事,改了立刻能看出效果,
|
|
18
|
+
* 也不需要额外解释。`maxAutoRounds` / `interruptDelaysMs` 这类带数值的旋钮
|
|
19
|
+
* 留在 profile 里——界面上给一个数字要配输入框与校验,不值当。
|
|
20
|
+
*/
|
|
21
|
+
export const SETTING_KEYS = ["autoContinue", "interruptRecovery"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从一个对象里挑出认识的开关,且值必须是布尔。
|
|
25
|
+
*
|
|
26
|
+
* 落盘的 `settings.json` 可能被人手改坏、也可能是旧版本留下的键,
|
|
27
|
+
* 所以这里**只认名单上的键**,其余一概忽略;值不是布尔的也忽略,
|
|
28
|
+
* 免得一个 `"false"` 字符串把 `cfg.autoContinue !== true` 那种判断带偏。
|
|
29
|
+
*
|
|
30
|
+
* @param value - 待挑选的对象,通常是落盘的设置。
|
|
31
|
+
* @returns 只含合法键值的新对象;没有合法的就是 `{}`。
|
|
32
|
+
*/
|
|
33
|
+
export function pickSettings(value) {
|
|
34
|
+
const picked = {};
|
|
35
|
+
if (value === null || typeof value !== "object") return picked;
|
|
36
|
+
for (const key of SETTING_KEYS) {
|
|
37
|
+
if (typeof value[key] === "boolean") picked[key] = value[key];
|
|
38
|
+
}
|
|
39
|
+
return picked;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把当前生效的开关压成面板要的形状。
|
|
44
|
+
*
|
|
45
|
+
* @param cfg - 合并后的配置。
|
|
46
|
+
* @returns `{ autoContinue, interruptRecovery }`,缺的按 false 报。
|
|
47
|
+
*/
|
|
48
|
+
export function settingsSnapshot(cfg) {
|
|
49
|
+
return {
|
|
50
|
+
autoContinue: cfg?.autoContinue === true,
|
|
51
|
+
interruptRecovery: cfg?.interruptRecovery === true,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - `surfaces/<sessionId>.json` 三层的折叠游标(只给界面看,不参与折叠)
|
|
8
8
|
* - `held/<sessionId>.json` 「被按住」:暂停 / 停止,**要跨重启保留**
|
|
9
9
|
* - `revive/<sessionId>.json` 意外中断的拉起重试链,**要跨重启保留**
|
|
10
|
+
* - `settings.json` 界面上的开关,**跟会话无关**,整个状态根一份
|
|
10
11
|
*
|
|
11
12
|
* 计划属于建立它的那个会话。放到会话无关的位置,任何一个会话的计划都会被
|
|
12
13
|
* 所有会话的 `agent/pre-step` 读到——那等于把不相干的节点指派塞进别人的上下文,
|
|
@@ -89,6 +90,9 @@ export function createStore(root) {
|
|
|
89
90
|
/** 意外中断重试链的文件路径。 */
|
|
90
91
|
const reviveFile = (sessionId) => file("revive", `${sessionId}.json`);
|
|
91
92
|
|
|
93
|
+
/** 设置文件的路径(与会话无关,整个状态根一份)。 */
|
|
94
|
+
const settingsFile = () => file("settings.json");
|
|
95
|
+
|
|
92
96
|
/** 读「被按住」状态;读不到或文件坏了,一律按"没被按住"处理。 */
|
|
93
97
|
function readHeldState(sessionId) {
|
|
94
98
|
if (!sessionId) return { paused: false, stopped: false, at: null };
|
|
@@ -176,6 +180,43 @@ export function createStore(root) {
|
|
|
176
180
|
writeJson(file("surfaces", `${sessionId}.json`), value);
|
|
177
181
|
},
|
|
178
182
|
|
|
183
|
+
// ---- 设置:**与会话无关**,整个状态根一份 ----
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 读界面上的开关。
|
|
187
|
+
*
|
|
188
|
+
* 为什么落盘:开关是"这台机器上的偏好",重启一次就忘掉等于没有这个开关。
|
|
189
|
+
* 它在 `apply()` 里盖在 profile 的 config 之上——**人在界面上的选择优先**,
|
|
190
|
+
* 否则界面改完、profile 一改又被顶回去,人会以为开关坏了。
|
|
191
|
+
*
|
|
192
|
+
* 只认自己认识的键:文件里多出别的字段一律忽略,坏文件当空。
|
|
193
|
+
*
|
|
194
|
+
* @returns `{ autoContinue?, interruptRecovery?, at? }`;没改过就是 `{}`。
|
|
195
|
+
*/
|
|
196
|
+
readSettings() {
|
|
197
|
+
const value = readJson(settingsFile(), null);
|
|
198
|
+
if (value === null || typeof value !== "object") return {};
|
|
199
|
+
return value;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 改几个开关,其余保持磁盘上的原样(读改写,与 `patchHeld` 同一个道理:
|
|
204
|
+
* 两个开关分属两条路径,谁都不能整份覆盖)。
|
|
205
|
+
*
|
|
206
|
+
* @param patch - `{ autoContinue?, interruptRecovery? }`;没给的键不动。
|
|
207
|
+
* @returns 写入后的完整设置对象。
|
|
208
|
+
*/
|
|
209
|
+
patchSettings(patch) {
|
|
210
|
+
const current = readJson(settingsFile(), null);
|
|
211
|
+
const next = current !== null && typeof current === "object" ? { ...current } : {};
|
|
212
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
213
|
+
if (typeof value === "boolean") next[key] = value;
|
|
214
|
+
}
|
|
215
|
+
next.at = Date.now();
|
|
216
|
+
writeJson(settingsFile(), next);
|
|
217
|
+
return next;
|
|
218
|
+
},
|
|
219
|
+
|
|
179
220
|
// ---- 「被按住」:暂停 / 停止。**要跨重启保留**,所以落盘 ----
|
|
180
221
|
|
|
181
222
|
/**
|
package/lib/tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 七个 `tree_task_*` 工具。
|
|
3
3
|
*
|
|
4
4
|
* **写树分成两个工具,不是可以合并的重复。** `tree_task_create` 与 `tree_task_done`
|
|
5
5
|
* 是折叠区间的两个边界(见 `lib/region.js`):`create` 是"开工"那一下,`done` 是
|
|
@@ -30,9 +30,11 @@ import {
|
|
|
30
30
|
addGoal,
|
|
31
31
|
complete,
|
|
32
32
|
createPlan,
|
|
33
|
+
deleteNode,
|
|
33
34
|
doneReply,
|
|
34
35
|
dropNode,
|
|
35
36
|
findNode,
|
|
37
|
+
goalsOf,
|
|
36
38
|
renderContinue,
|
|
37
39
|
renderGoal,
|
|
38
40
|
renderTree,
|
|
@@ -301,5 +303,50 @@ export function registerTools(ctx, { store, cfg }) {
|
|
|
301
303
|
},
|
|
302
304
|
});
|
|
303
305
|
|
|
306
|
+
ctx.tools.register({
|
|
307
|
+
name: "tree_task_delete",
|
|
308
|
+
description:
|
|
309
|
+
"从树里删掉一个节点——**清理**用的,不是「这件事不做了」。" +
|
|
310
|
+
"丢弃(tree_task_drop)是留个记号:这件事我不做,但树里还查得到它曾经有过;" +
|
|
311
|
+
"删除是把内容凭空摘掉,连同它下面的任务或子任务一起," +
|
|
312
|
+
"**不看状态**(已完成、已丢弃的都删得掉)。" +
|
|
313
|
+
"内容写错了、建重了、结果记在了错的地方,就用这个。",
|
|
314
|
+
parameters: {
|
|
315
|
+
type: "object",
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
properties: {
|
|
318
|
+
id: { type: "string", description: "要删除的节点 id" },
|
|
319
|
+
},
|
|
320
|
+
required: ["id"],
|
|
321
|
+
},
|
|
322
|
+
output: OUTPUT,
|
|
323
|
+
async execute(args, exec) {
|
|
324
|
+
const plan = store.readPlan(sessionOf(exec));
|
|
325
|
+
if (!plan) return ok("还没有计划。");
|
|
326
|
+
// 删掉之后就查不到"这次动的是哪个目标"了,先记一份。
|
|
327
|
+
const touched = findNode(plan, args?.id);
|
|
328
|
+
const result = deleteNode(plan, args?.id);
|
|
329
|
+
if (!result.ok) return ok(`没有删除:${result.reason}`);
|
|
330
|
+
store.writePlan(sessionOf(exec), plan);
|
|
331
|
+
|
|
332
|
+
const lines = [`已删除:${result.removed.join("、")}`, ""];
|
|
333
|
+
if (touched !== null && touched.level !== "goal") {
|
|
334
|
+
// 删的是任务或子任务:它所在的那个目标还在,照旧只给它一个目标的树。
|
|
335
|
+
lines.push(renderGoal(touched.goal, plan, true));
|
|
336
|
+
} else {
|
|
337
|
+
// 删的是目标:那个目标已经不存在了,没有"它所在的树"可给。
|
|
338
|
+
// 剩下的目标数说一句就够——要看细节再调 tree_task_status。
|
|
339
|
+
const rest = goalsOf(plan).length;
|
|
340
|
+
lines.push(
|
|
341
|
+
rest === 0
|
|
342
|
+
? "计划树现在空了。(要用就用 tree_task_create 建新目标。)"
|
|
343
|
+
: `这个会话还剩 ${rest} 个目标,要看细节用 tree_task_status。`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
lines.push("", renderContinue(plan));
|
|
347
|
+
return ok(lines.join("\n"));
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
304
351
|
void cfg;
|
|
305
352
|
}
|