dsh-tree-task-flow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/client.js +921 -0
- package/cordis.patch.yml +20 -0
- package/enable.example.yml +4 -0
- package/lib/auto-continue.js +132 -0
- package/lib/commands.js +189 -0
- package/lib/http.js +243 -0
- package/lib/index.js +110 -0
- package/lib/plan.js +430 -0
- package/lib/prompt.js +56 -0
- package/lib/region.js +345 -0
- package/lib/store.js +126 -0
- package/lib/tools.js +308 -0
- package/package.json +68 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 本插件的 bundle 配置层:把 task-tree 这一行插入 profile 的插件行列表。
|
|
2
|
+
#
|
|
3
|
+
# package.json 的 dsh.bundle.patch 指向本文件,因此安装成 bundle 后,
|
|
4
|
+
# 这一层会自动挂进 `dsh.profile.bundles` 配置栈(优先级低于 profile 自身的
|
|
5
|
+
# cordis.patch.yml)。
|
|
6
|
+
#
|
|
7
|
+
# 这里刻意不写 config,全部走 lib/index.js 里的 DEFAULT_CONFIG。
|
|
8
|
+
# 要临时关掉或改默认值,请在 profile 自己的 cordis.patch.yml 里覆盖这一行。
|
|
9
|
+
#
|
|
10
|
+
# ⚠️ 同 id 的 config 是**整体替换**,不是逐字段深合并——覆盖时要把需要的
|
|
11
|
+
# 字段一次写全。可覆盖的字段见 DEFAULT_CONFIG:
|
|
12
|
+
#
|
|
13
|
+
# enabled 总开关 默认 false(装上 ≠ 启用)
|
|
14
|
+
# autoContinue 是否允许自动续行 默认 false(开着会让人停不下来)
|
|
15
|
+
# maxAutoRounds 连续自动续行轮次上限 默认 5
|
|
16
|
+
# promptOrder 静态说明段顺序 默认 2000
|
|
17
|
+
# rootDir 插件状态目录 默认 $DSH_HOME/dsh-task-tree
|
|
18
|
+
- insert:
|
|
19
|
+
- id: task-tree
|
|
20
|
+
name: 'dsh-tree-task-flow'
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自动续行兜底。
|
|
3
|
+
*
|
|
4
|
+
* 主路径其实**不需要这里**:`tree_task_done` 的返回文本里已经带了"请继续执行下一个步骤",
|
|
5
|
+
* 模型通常会在同一轮里接着往下做。
|
|
6
|
+
*
|
|
7
|
+
* 这里兜的是"模型仍然停下来"的情况:本轮结束、agent 回到 `idle` 时,
|
|
8
|
+
* 如果计划还在进行中,就推一条续行消息把下一轮唤醒。
|
|
9
|
+
*
|
|
10
|
+
* 三条安全阀缺一不可(方案 §7.1):
|
|
11
|
+
*
|
|
12
|
+
* 1. **有活跃的内置 goal 时完全不插手**——不只是"别抢方向盘"。
|
|
13
|
+
* `dsh-goal-round-driver` 每推进一轮都设 `startsRequestSeries: true`,
|
|
14
|
+
* 那会让系统提示词走归一化分支;节点 0 一旦被判定需要重写,
|
|
15
|
+
* 整个上下文从第一个 token 起失去缓存。
|
|
16
|
+
* 2. **队列里还有人在等的时候绝不抢**:`inbox.nextTurn` / `nextStep` 非空就退出。
|
|
17
|
+
* 3. **连续续行轮次有上限**,达到就停,避免把 token 烧在一个卡住的任务上。
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-tree-task-flow/auto-continue
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { activePath, renderContinue } from "./plan.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 注册续行兜底。
|
|
26
|
+
*
|
|
27
|
+
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
28
|
+
* @param options - `{ store, cfg }`。
|
|
29
|
+
*/
|
|
30
|
+
export function registerAutoContinue(ctx, { store, cfg }) {
|
|
31
|
+
/** 每个会话连续续行了多少次。 */
|
|
32
|
+
const rounds = new Map();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 被用户强行打断过的会话——**不再自动续行**。
|
|
36
|
+
*
|
|
37
|
+
* 这是"会话停不下来"的解法。用户按下停止 → 本轮中止 → agent 回到 idle;
|
|
38
|
+
* 没有这道闸,下面的 `agent/status` 监听器会立刻把它再拉起来,用户就永远停不下来。
|
|
39
|
+
* `agent/turn-stopping` 的 payload 带 `signal`,`signal.aborted` 正是
|
|
40
|
+
* "这一轮是被中止的"这个事实。
|
|
41
|
+
*/
|
|
42
|
+
const stopped = new Set();
|
|
43
|
+
|
|
44
|
+
ctx.on("agent/turn-stopping", (payload) => {
|
|
45
|
+
const sessionId = payload?.agent?.session?.id;
|
|
46
|
+
if (sessionId === undefined) return;
|
|
47
|
+
if (payload?.signal?.aborted === true) stopped.add(sessionId);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 只有**真人**发的消息才重置配额。
|
|
51
|
+
//
|
|
52
|
+
// 必须按来源区分,不能按"这一步有没有消息":`agent.followup()` 推的消息同样会
|
|
53
|
+
// 从 inbox 进入下一步的 `payload.messages`,那样每一轮都会把自己刚用掉的配额
|
|
54
|
+
// 重置回去,`maxAutoRounds` 也就失去了意义。
|
|
55
|
+
// `source.kind === 'user'` 才是真人,插件推的是 `'plugin'`。
|
|
56
|
+
//
|
|
57
|
+
// 注意这是 waterfall:必须 await next() 并把它返回,否则会截断监听器链。
|
|
58
|
+
ctx.on("agent/pre-step", async (payload, next) => {
|
|
59
|
+
const decision = await next();
|
|
60
|
+
const sessionId = payload?.agent?.session?.id;
|
|
61
|
+
if (sessionId === undefined) return decision;
|
|
62
|
+
const fromHuman = (payload?.messages ?? []).some(
|
|
63
|
+
(message) => message?.source?.kind === "user",
|
|
64
|
+
);
|
|
65
|
+
if (fromHuman) {
|
|
66
|
+
rounds.set(sessionId, 0);
|
|
67
|
+
// 人回来了,解除中断闸门,允许新一轮的续行。
|
|
68
|
+
stopped.delete(sessionId);
|
|
69
|
+
}
|
|
70
|
+
return decision;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ctx.on("agent/status", (payload) => {
|
|
74
|
+
if (cfg.autoContinue !== true) return;
|
|
75
|
+
if (payload?.status !== "idle") return;
|
|
76
|
+
|
|
77
|
+
const agent = payload.agent;
|
|
78
|
+
const sessionId = agent?.session?.id;
|
|
79
|
+
if (agent === undefined || sessionId === undefined) return;
|
|
80
|
+
|
|
81
|
+
// ★ 安全阀 0:这一轮是被用户**中止**的 → 绝不能把它拉起来。
|
|
82
|
+
if (stopped.has(sessionId)) return;
|
|
83
|
+
|
|
84
|
+
// 安全阀 1:内置 goal 活跃时完全不插手。
|
|
85
|
+
const goal = ctx.get?.("goals")?.get?.(agent);
|
|
86
|
+
if (goal !== undefined && goal !== null && goal.phase === "active") return;
|
|
87
|
+
|
|
88
|
+
// 安全阀 2:已经有排队输入,别抢在用户前面。
|
|
89
|
+
const inbox = agent.inbox;
|
|
90
|
+
if ((inbox?.nextTurn?.length ?? 0) > 0 || (inbox?.nextStep?.length ?? 0) > 0) return;
|
|
91
|
+
|
|
92
|
+
// 安全阀 3:轮次上限。
|
|
93
|
+
const used = rounds.get(sessionId) ?? 0;
|
|
94
|
+
if (used >= cfg.maxAutoRounds) return;
|
|
95
|
+
|
|
96
|
+
// 只有"**本会话的**计划还在进行中"才值得续。
|
|
97
|
+
const plan = store.readPlan(sessionId);
|
|
98
|
+
const active = activePath(plan);
|
|
99
|
+
if (active === null || active.step === null) return;
|
|
100
|
+
|
|
101
|
+
rounds.set(sessionId, used + 1);
|
|
102
|
+
try {
|
|
103
|
+
agent.followup({
|
|
104
|
+
id: `tt-continue-${sessionId}-${Date.now().toString(36)}`,
|
|
105
|
+
role: "user",
|
|
106
|
+
content: [{ type: "text", text: renderContinue(plan) }],
|
|
107
|
+
source: { kind: "plugin", plugin: "dsh-tree-task-flow", form: "notice", summary: "计划续行" },
|
|
108
|
+
});
|
|
109
|
+
ctx.logger?.info?.(
|
|
110
|
+
`dsh-tree-task-flow: 自动续行 ${used + 1}/${cfg.maxAutoRounds}(步骤 ${active.step.id})`,
|
|
111
|
+
);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
ctx.logger?.warn?.(`dsh-tree-task-flow: 自动续行失败:${String(error?.message ?? error)}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 交给 /task-tree 命令用的控制接口:人得有一句话就能接管的手段。
|
|
118
|
+
return {
|
|
119
|
+
/** 停止某个会话的自动续行(用户中断时自动调用,也可由 /task-tree stop 显式调用)。 */
|
|
120
|
+
stop(sessionId) {
|
|
121
|
+
if (sessionId !== undefined) stopped.add(sessionId);
|
|
122
|
+
},
|
|
123
|
+
/** 重新允许某个会话自动续行。 */
|
|
124
|
+
resume(sessionId) {
|
|
125
|
+
if (sessionId !== undefined) stopped.delete(sessionId);
|
|
126
|
+
},
|
|
127
|
+
/** 这个会话当前是不是被停止状态。 */
|
|
128
|
+
isStopped(sessionId) {
|
|
129
|
+
return stopped.has(sessionId);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/task-tree` 斜杠命令:给人用的入口。
|
|
3
|
+
*
|
|
4
|
+
* 命令**不经过模型**——`handler` 直接执行、直接返回文本。所以它是查看和管理
|
|
5
|
+
* 任务树最直接的方式,也是"把自动执行停下来"的那个开关:
|
|
6
|
+
* 任务自动执行必须是**受控**的,人得有一句话就能接管的手段。
|
|
7
|
+
*
|
|
8
|
+
* 命令同时是**检查点的旁路**:检查点闸门只拦模型(`tools/pre-execute`),
|
|
9
|
+
* 拦不住人。人在检查点里照样能提交结果、丢弃节点、或者把自动续行停掉。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-tree-task-flow/commands
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { activePath, checkpointOf, complete, dropNode, levelLabel, renderTree } from "./plan.js";
|
|
15
|
+
|
|
16
|
+
/** 用法说明,status 和 help 都会带上。 */
|
|
17
|
+
const USAGE = [
|
|
18
|
+
"/task-tree 查看本会话的任务树与检查点状态",
|
|
19
|
+
"/task-tree done <id> <结果> 提交某个节点的结果并完成它",
|
|
20
|
+
"/task-tree drop <id> 丢弃某个节点",
|
|
21
|
+
"/task-tree stop 停止本会话的自动续行",
|
|
22
|
+
"/task-tree resume 重新允许自动续行(需 config 里 autoContinue 为 true)",
|
|
23
|
+
"/task-tree reset 清掉本会话的计划树",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
/** 未启用时唯一要说的话:装了,但没开,以及怎么开。 */
|
|
27
|
+
const DISABLED_NOTE =
|
|
28
|
+
"dsh-tree-task-flow 已装载但**未启用**(config 里 enabled 不是 true):它没有注册工具," +
|
|
29
|
+
"也不会折叠任何上下文。要启用请在 profile 的 cordis.patch.yml 里把 enabled 设为 true。";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 把检查点那句话拼出来。措辞与 `plan.js` 的检查点提示、`tools.js` 的闸门拒绝文案一致。
|
|
33
|
+
*
|
|
34
|
+
* @param checkpoint - `checkpointOf(plan)` 的结果。
|
|
35
|
+
* @returns 两行文本。
|
|
36
|
+
*/
|
|
37
|
+
function checkpointLines(checkpoint) {
|
|
38
|
+
const label = levelLabel(checkpoint.level);
|
|
39
|
+
return [
|
|
40
|
+
`检查点:${label}「${checkpoint.node.title}」(${checkpoint.node.id}) 的子节点已全部结束。`,
|
|
41
|
+
`此刻只有两条路:tree_task_plan 继续给 ${checkpoint.node.id} 拆子节点,` +
|
|
42
|
+
`或者 tree_task_done(id="${checkpoint.node.id}", result="…") 提交这个${label}的结果并完成它。`,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 注册命令。
|
|
48
|
+
*
|
|
49
|
+
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
50
|
+
* @param options - `{ store, cfg, autoContinue, enabled }`;`autoContinue` 是
|
|
51
|
+
* `registerAutoContinue` 返回的控制接口,未启用时为 null。
|
|
52
|
+
*/
|
|
53
|
+
export function registerCommands(ctx, { store, cfg, autoContinue, enabled = false }) {
|
|
54
|
+
// 和 tools 一样,命令服务不是每个部署都有;用 get 探测而不是写进 inject。
|
|
55
|
+
const commands = ctx.get?.("commands");
|
|
56
|
+
if (commands === undefined || commands === null) {
|
|
57
|
+
ctx.logger?.debug?.("dsh-tree-task-flow: 没有 commands 服务,/task-tree 未注册");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
commands.register({
|
|
62
|
+
name: "task-tree",
|
|
63
|
+
description: "查看与管理任务树:status / done <id> <结果> / drop <id> / stop / resume / reset",
|
|
64
|
+
input: { hint: "status | done <id> <结果> | drop <id> | stop | resume | reset" },
|
|
65
|
+
handler: (invocation) => {
|
|
66
|
+
const sessionId = invocation?.agent?.session?.id;
|
|
67
|
+
if (sessionId === undefined) {
|
|
68
|
+
return { kind: "error", text: "拿不到当前会话。" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const raw = String(invocation?.rawInput ?? "").trim();
|
|
72
|
+
const [verb = "status", ...rest] = raw === "" ? [] : raw.split(/\s+/u);
|
|
73
|
+
|
|
74
|
+
if (verb === "help") {
|
|
75
|
+
return { kind: "success", text: enabled === true ? USAGE : `${DISABLED_NOTE}\n\n${USAGE}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 未启用时插件没有注册工具,也就不会有计划树;除了查看"装了但没开",
|
|
79
|
+
// 其余动词一概不执行——启用的插件一个字节都不该碰会话。
|
|
80
|
+
if (enabled !== true && verb !== "status") {
|
|
81
|
+
return {
|
|
82
|
+
kind: "error",
|
|
83
|
+
text: `${DISABLED_NOTE}\n\n未启用时只有 /task-tree status 与 /task-tree help 可用。`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (verb === "stop") {
|
|
88
|
+
autoContinue?.stop?.(sessionId);
|
|
89
|
+
return {
|
|
90
|
+
kind: "success",
|
|
91
|
+
text: "已停止本会话的自动续行。任务树的进度没有变,随时可以继续。",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (verb === "resume") {
|
|
96
|
+
autoContinue?.resume?.(sessionId);
|
|
97
|
+
return {
|
|
98
|
+
kind: "success",
|
|
99
|
+
text:
|
|
100
|
+
cfg.autoContinue === true
|
|
101
|
+
? "已重新允许本会话自动续行。"
|
|
102
|
+
: "已解除中断标记,但配置里 autoContinue 是 false,所以仍然不会自动续行。",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (verb === "reset") {
|
|
107
|
+
store.writePlan(sessionId, null);
|
|
108
|
+
return { kind: "success", text: "已清掉本会话的计划树。" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (verb === "done") {
|
|
112
|
+
const id = rest[0];
|
|
113
|
+
const result = rest.slice(1).join(" ").trim();
|
|
114
|
+
if (id === undefined) {
|
|
115
|
+
return { kind: "error", text: `用法:/task-tree done <节点 id> <结果>\n\n${USAGE}` };
|
|
116
|
+
}
|
|
117
|
+
if (result === "") {
|
|
118
|
+
return {
|
|
119
|
+
kind: "error",
|
|
120
|
+
text:
|
|
121
|
+
"完成必须提交结果:请写清这个节点产出了什么。" +
|
|
122
|
+
"插件会用它顶替这个节点从开始到现在的整段执行过程,所以要写成能独立看懂的样子。",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const plan = store.readPlan(sessionId);
|
|
126
|
+
if (plan === null) return { kind: "error", text: "本会话还没有计划。" };
|
|
127
|
+
const outcome = complete(plan, id, result);
|
|
128
|
+
if (!outcome.ok) return { kind: "error", text: `没有完成:${outcome.reason}` };
|
|
129
|
+
store.writePlan(sessionId, plan);
|
|
130
|
+
// `complete` 已经把检查点提示放进 events 了,这里只补上折叠后的树。
|
|
131
|
+
return { kind: "success", text: [...outcome.events, "", renderTree(plan)].join("\n") };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (verb === "drop") {
|
|
135
|
+
const id = rest[0];
|
|
136
|
+
if (id === undefined) {
|
|
137
|
+
return { kind: "error", text: `用法:/task-tree drop <节点 id>\n\n${USAGE}` };
|
|
138
|
+
}
|
|
139
|
+
const plan = store.readPlan(sessionId);
|
|
140
|
+
if (plan === null) return { kind: "error", text: "本会话还没有计划。" };
|
|
141
|
+
const result = dropNode(plan, id);
|
|
142
|
+
if (!result.ok) return { kind: "error", text: `没有丢弃:${result.reason}` };
|
|
143
|
+
store.writePlan(sessionId, plan);
|
|
144
|
+
return {
|
|
145
|
+
kind: "success",
|
|
146
|
+
text: [`已丢弃:${result.dropped.join("、")}`, "", renderTree(plan)].join("\n"),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (verb !== "status") {
|
|
151
|
+
return { kind: "error", text: `不认识的子命令:${verb}\n\n${USAGE}` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const plan = store.readPlan(sessionId);
|
|
155
|
+
const lines = [renderTree(plan)];
|
|
156
|
+
|
|
157
|
+
if (enabled !== true) {
|
|
158
|
+
lines.push("", DISABLED_NOTE);
|
|
159
|
+
} else if (plan === null) {
|
|
160
|
+
lines.push("", "本会话还没有计划。用 tree_task_create 建立目标与任务。");
|
|
161
|
+
} else {
|
|
162
|
+
// 检查点优先于"当前节点":那一刻要推进的不是某个子任务,
|
|
163
|
+
// 而是"这一层到底做完了没有"这个判断。
|
|
164
|
+
const checkpoint = checkpointOf(plan);
|
|
165
|
+
const active = activePath(plan);
|
|
166
|
+
if (checkpoint !== null) {
|
|
167
|
+
lines.push("", ...checkpointLines(checkpoint));
|
|
168
|
+
} else if (active === null) {
|
|
169
|
+
lines.push("", "计划树上的所有节点都已结束。请向用户汇报最终结果。");
|
|
170
|
+
} else if (active.step !== null) {
|
|
171
|
+
lines.push("", `当前子任务:${active.step.id}「${active.step.title}」`);
|
|
172
|
+
} else if (active.task !== null) {
|
|
173
|
+
lines.push(
|
|
174
|
+
"",
|
|
175
|
+
`当前任务:${active.task.id}「${active.task.title}」(还没有子任务 → tree_task_plan)`,
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
lines.push(
|
|
179
|
+
"",
|
|
180
|
+
`当前目标:${active.goal.id}「${active.goal.title}」(还没有任务 → tree_task_plan)`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
lines.push("", USAGE);
|
|
186
|
+
return { kind: "success", text: lines.join("\n") };
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置面板用的 HTTP 接口。
|
|
3
|
+
*
|
|
4
|
+
* 面板跑在浏览器里,拿不到宿主内存中的 store,所以数据要经由这里出去。
|
|
5
|
+
* 全部挂在 `/dsh-tree-task-flow/` 前缀下;只读写插件**自己的**状态目录,
|
|
6
|
+
* 不碰会话日志(那是后端内部格式)。
|
|
7
|
+
*
|
|
8
|
+
* 面板要显示两样东西,都在这里算好给它:
|
|
9
|
+
*
|
|
10
|
+
* - **每层的结果**:节点完成时提交的文本。面板不自己去挖计划树的深层结构,
|
|
11
|
+
* 接口直接把三层各自的结果摆出来。
|
|
12
|
+
* - **检查点状态**:一个节点自己的子节点全部结束、而它自己还开着的那一刻。
|
|
13
|
+
* 此刻只有两条路——继续拆子节点,或者提交结果并完成它。
|
|
14
|
+
*
|
|
15
|
+
* 面板上的按钮都写计划树,所以写完要把**新的**活动路径、结果与检查点一并回传,
|
|
16
|
+
* 免得面板还要再发一次请求才知道自己按出了什么效果。
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-tree-task-flow/http
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdirSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { activePath, checkpointOf, complete, dropNode, levelLabel } from "./plan.js";
|
|
25
|
+
|
|
26
|
+
/** 统一的 JSON 响应。 */
|
|
27
|
+
function sendJson(response, status, body) {
|
|
28
|
+
response.writeHead(status, {
|
|
29
|
+
"content-type": "application/json; charset=utf-8",
|
|
30
|
+
"cache-control": "no-store",
|
|
31
|
+
});
|
|
32
|
+
response.end(JSON.stringify(body));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 读请求体并解析 JSON;读不动就当空对象,别让面板拿到 500。 */
|
|
36
|
+
async function readJsonBody(request) {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
39
|
+
if (chunks.length === 0) return {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 活动路径压成面板要的形状:三层各自的 id,没到那层就是 null。 */
|
|
48
|
+
function activeInfo(plan) {
|
|
49
|
+
const active = activePath(plan);
|
|
50
|
+
if (active === null) return null;
|
|
51
|
+
return {
|
|
52
|
+
goalId: active.goal.id,
|
|
53
|
+
taskId: active.task?.id ?? null,
|
|
54
|
+
stepId: active.step?.id ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 检查点压成面板要的形状;没有检查点就是 null。 */
|
|
59
|
+
function checkpointInfo(plan) {
|
|
60
|
+
const found = checkpointOf(plan);
|
|
61
|
+
if (found === null) return null;
|
|
62
|
+
return {
|
|
63
|
+
id: found.node.id,
|
|
64
|
+
level: found.level,
|
|
65
|
+
label: levelLabel(found.level),
|
|
66
|
+
title: found.node.title,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 一个节点提交过的结果;还没提交就是 null。 */
|
|
71
|
+
function resultOf(node) {
|
|
72
|
+
if (!node?.result) return null;
|
|
73
|
+
return {
|
|
74
|
+
id: node.id,
|
|
75
|
+
title: node.title,
|
|
76
|
+
text: node.result.text,
|
|
77
|
+
at: node.result.at,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 活动路径上每一层各自提交的结果。 */
|
|
82
|
+
function levelResults(plan) {
|
|
83
|
+
const active = activePath(plan);
|
|
84
|
+
return {
|
|
85
|
+
goal: resultOf(active?.goal),
|
|
86
|
+
task: resultOf(active?.task),
|
|
87
|
+
step: resultOf(active?.step),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 一次写操作之后,面板需要的全部新状态。 */
|
|
92
|
+
function afterWrite(plan) {
|
|
93
|
+
return {
|
|
94
|
+
active: activeInfo(plan),
|
|
95
|
+
checkpoint: checkpointInfo(plan),
|
|
96
|
+
results: levelResults(plan),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 列出状态目录里所有**有计划的会话**。 */
|
|
101
|
+
function listSessions(store) {
|
|
102
|
+
let names = [];
|
|
103
|
+
try {
|
|
104
|
+
names = readdirSync(join(store.root, "plans"));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (!name.endsWith(".json")) continue;
|
|
112
|
+
const sessionId = name.slice(0, -".json".length);
|
|
113
|
+
const plan = store.readPlan(sessionId);
|
|
114
|
+
if (plan === null) continue;
|
|
115
|
+
const steps = (plan.goal?.tasks ?? []).flatMap((task) => task.steps ?? []);
|
|
116
|
+
out.push({
|
|
117
|
+
sessionId,
|
|
118
|
+
goal: plan.goal?.title ?? "(未命名目标)",
|
|
119
|
+
status: plan.goal?.status ?? "pending",
|
|
120
|
+
updatedAt: plan.updatedAt ?? 0,
|
|
121
|
+
steps: steps.length,
|
|
122
|
+
done: steps.filter((step) => step.status === "done").length,
|
|
123
|
+
result: plan.goal?.result?.text ?? null,
|
|
124
|
+
checkpoint: checkpointInfo(plan),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 注册全部面板接口。
|
|
133
|
+
*
|
|
134
|
+
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
135
|
+
* @param options - `{ store, cfg, autoContinue }`。
|
|
136
|
+
*/
|
|
137
|
+
export function registerHttp(ctx, { store, cfg, autoContinue }) {
|
|
138
|
+
ctx.inject(["webServer"], (host) => {
|
|
139
|
+
host.effect(() => {
|
|
140
|
+
// 会话列表:面板顶部的下拉框。
|
|
141
|
+
host.webServer.register(
|
|
142
|
+
{
|
|
143
|
+
kind: "exact",
|
|
144
|
+
path: "/dsh-tree-task-flow/sessions",
|
|
145
|
+
handler: async (request, response) => {
|
|
146
|
+
if (request.method !== "GET") return sendJson(response, 405, { ok: false, error: "只支持 GET" });
|
|
147
|
+
sendJson(response, 200, {
|
|
148
|
+
ok: true,
|
|
149
|
+
enabled: cfg.enabled === true,
|
|
150
|
+
sessions: listSessions(store),
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
"dsh-tree-task-flow: sessions",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// 一棵树的全部信息:结构 + 每层结果 + 检查点 + 当前路径 + 三层折叠游标。
|
|
158
|
+
host.webServer.register(
|
|
159
|
+
{
|
|
160
|
+
kind: "exact",
|
|
161
|
+
path: "/dsh-tree-task-flow/tree",
|
|
162
|
+
handler: async (request, response) => {
|
|
163
|
+
if (request.method !== "GET") return sendJson(response, 405, { ok: false, error: "只支持 GET" });
|
|
164
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
165
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
166
|
+
const plan = store.readPlan(sessionId);
|
|
167
|
+
if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话没有计划" });
|
|
168
|
+
|
|
169
|
+
const cursors = store.readSurfaces(sessionId);
|
|
170
|
+
sendJson(response, 200, {
|
|
171
|
+
ok: true,
|
|
172
|
+
plan,
|
|
173
|
+
...afterWrite(plan),
|
|
174
|
+
cursors: {
|
|
175
|
+
goal: cursors.goal ?? null,
|
|
176
|
+
task: cursors.task ?? null,
|
|
177
|
+
step: cursors.step ?? null,
|
|
178
|
+
},
|
|
179
|
+
stopped: autoContinue?.isStopped?.(sessionId) === true,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
"dsh-tree-task-flow: tree",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// 面板上的按钮都走这一个端点。
|
|
187
|
+
host.webServer.register(
|
|
188
|
+
{
|
|
189
|
+
kind: "exact",
|
|
190
|
+
path: "/dsh-tree-task-flow/action",
|
|
191
|
+
handler: async (request, response) => {
|
|
192
|
+
if (request.method !== "POST") return sendJson(response, 405, { ok: false, error: "只支持 POST" });
|
|
193
|
+
const body = await readJsonBody(request);
|
|
194
|
+
const sessionId = String(body.sessionId ?? "");
|
|
195
|
+
const action = String(body.action ?? "");
|
|
196
|
+
|
|
197
|
+
if (action === "stop" || action === "resume") {
|
|
198
|
+
autoContinue?.[action]?.(sessionId);
|
|
199
|
+
return sendJson(response, 200, {
|
|
200
|
+
ok: true,
|
|
201
|
+
stopped: autoContinue?.isStopped?.(sessionId) === true,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (action === "reset") {
|
|
206
|
+
store.writePlan(sessionId, null);
|
|
207
|
+
// 计划没了,游标也必须跟着清:游标里记的是"这一层总结到哪儿了",
|
|
208
|
+
// 留着旧节点 id,下一次建树的第一层就会被误判成"没换节点"。
|
|
209
|
+
store.writeSurfaces(sessionId, {
|
|
210
|
+
sessionId,
|
|
211
|
+
goal: null,
|
|
212
|
+
task: null,
|
|
213
|
+
step: null,
|
|
214
|
+
updatedAt: Date.now(),
|
|
215
|
+
});
|
|
216
|
+
return sendJson(response, 200, { ok: true, active: null, checkpoint: null, results: null });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (action === "done" || action === "drop") {
|
|
220
|
+
const plan = store.readPlan(sessionId);
|
|
221
|
+
if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话没有计划" });
|
|
222
|
+
const id = String(body.id ?? "");
|
|
223
|
+
// 完成必须带结果:插件要用它替换掉这个节点的执行过程。
|
|
224
|
+
const outcome = action === "done" ? complete(plan, id, body.result) : dropNode(plan, id);
|
|
225
|
+
if (!outcome.ok) return sendJson(response, 200, { ok: false, error: outcome.reason });
|
|
226
|
+
store.writePlan(sessionId, plan);
|
|
227
|
+
return sendJson(response, 200, {
|
|
228
|
+
ok: true,
|
|
229
|
+
events: outcome.events ?? outcome.dropped ?? [],
|
|
230
|
+
...afterWrite(plan),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return sendJson(response, 400, { ok: false, error: `不认识的动作:${action}` });
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
"dsh-tree-task-flow: action",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
ctx.logger?.info?.("dsh-tree-task-flow: 设置面板接口已挂载");
|
|
241
|
+
}, "dsh-tree-task-flow: http");
|
|
242
|
+
});
|
|
243
|
+
}
|