tellrun 2.1.1 → 2.1.2
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/dist/agents/codex.js +21 -2
- package/dist/agents/codex.js.map +1 -1
- package/dist/core/attachments.js +6 -0
- package/dist/core/attachments.js.map +1 -0
- package/dist/core/cron.js +21 -21
- package/dist/core/cron.js.map +1 -1
- package/dist/core/menus.js +20 -20
- package/dist/core/relay.js +46 -46
- package/dist/core/relay.js.map +1 -1
- package/dist/{storage → core}/schema.js +15 -9
- package/dist/core/schema.js.map +1 -0
- package/dist/core/state.js +766 -0
- package/dist/core/state.js.map +1 -0
- package/dist/im/telegram-files.js +32 -1
- package/dist/im/telegram-files.js.map +1 -1
- package/dist/im/telegram.js +51 -24
- package/dist/im/telegram.js.map +1 -1
- package/dist/main.js +10 -3
- package/dist/main.js.map +1 -1
- package/dist/storage/attachments.js +1 -5
- package/dist/storage/attachments.js.map +1 -1
- package/dist/storage/store.js +13 -752
- package/dist/storage/store.js.map +1 -1
- package/docs/architecture.md +45 -13
- package/package.json +1 -1
- package/dist/storage/schema.js.map +0 -1
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import { createCrons } from "./cron.js";
|
|
2
|
+
import { createMenus } from "./menus.js";
|
|
3
|
+
import { activeTaskStatuses } from "./schema.js";
|
|
4
|
+
/** 管理业务状态及事务;初始快照、保存方式和默认目录由组装入口提供。 */
|
|
5
|
+
export function createState(options) {
|
|
6
|
+
let state = structuredClone(options.initialState ?? {
|
|
7
|
+
channels: [],
|
|
8
|
+
inboundUpdates: [],
|
|
9
|
+
nextCronId: 1,
|
|
10
|
+
crons: [],
|
|
11
|
+
tasks: [],
|
|
12
|
+
interactions: [],
|
|
13
|
+
outbox: [],
|
|
14
|
+
});
|
|
15
|
+
if (!options.initialState)
|
|
16
|
+
options.save(state);
|
|
17
|
+
let updating = false;
|
|
18
|
+
/** 流式预览只保留内存中的最新片段,不为每个文本增量写磁盘。 */
|
|
19
|
+
let draft;
|
|
20
|
+
/** 单个发送循环正在处理的回复;交互关闭时仍需保留它的消息清理关联。 */
|
|
21
|
+
let delivering;
|
|
22
|
+
/** 交互撤回可能清空发送队列;实例内保持递增,避免迟到发送回执删除新消息。 */
|
|
23
|
+
let replyId = state.outbox.reduce((max, item) => Math.max(max, item.id), 0);
|
|
24
|
+
/** 一次业务操作只保存一个快照;嵌套操作合并提交,保存失败时回滚内存。 */
|
|
25
|
+
function update(action) {
|
|
26
|
+
if (updating)
|
|
27
|
+
return action();
|
|
28
|
+
const previous = state;
|
|
29
|
+
state = structuredClone(state);
|
|
30
|
+
updating = true;
|
|
31
|
+
try {
|
|
32
|
+
const result = action();
|
|
33
|
+
/** state 只保留运行所需记录;发送中的交互及待发送编辑不能提前回收。 */
|
|
34
|
+
state.interactions = state.interactions.filter((item) => item.status !== "closed" ||
|
|
35
|
+
item.id === delivering?.interactionId ||
|
|
36
|
+
state.outbox.some((reply) => reply.interactionId === item.id));
|
|
37
|
+
const latest = new Set(state.channels.map((channel) => state.tasks.findLast((task) => task.channelId === channel.id && !activeTaskStatuses.has(task.status))?.id));
|
|
38
|
+
state.tasks = state.tasks.filter((task) => activeTaskStatuses.has(task.status) ||
|
|
39
|
+
latest.has(task.id) ||
|
|
40
|
+
state.interactions.some((item) => item.taskId === task.id));
|
|
41
|
+
options.save(state);
|
|
42
|
+
return structuredClone(result);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
state = previous;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
updating = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** 回复按展示格式分段,并与触发它的状态变更一起保存。 */
|
|
53
|
+
function enqueueReply(channelId, text, { attachments = [], ...delivery } = {}) {
|
|
54
|
+
if (text === undefined)
|
|
55
|
+
return;
|
|
56
|
+
const parts = delivery.messageId
|
|
57
|
+
? [text]
|
|
58
|
+
: delivery.markdown
|
|
59
|
+
? [text || "(No text reply)"]
|
|
60
|
+
: (text.match(/[\s\S]{1,2000}/gu) ?? ["(No text reply)"]);
|
|
61
|
+
if (delivery.markdown && !delivery.messageId && text.length > 30000) {
|
|
62
|
+
parts.length = 0;
|
|
63
|
+
let part = "";
|
|
64
|
+
/** 保存围栏与语言标记,代码跨段时关闭并在下一段重新打开。 */
|
|
65
|
+
let fence;
|
|
66
|
+
for (const line of text.match(/[^\n]*\n|[^\n]+$/gu) ?? []) {
|
|
67
|
+
/** 预留编号与围栏空间;超长单行按 Unicode 字符拆分,保留全部内容。 */
|
|
68
|
+
for (const chunk of line.match(/[\s\S]{1,14000}/gu) ?? []) {
|
|
69
|
+
if (part.length + chunk.length > 30000) {
|
|
70
|
+
parts.push(`${part}${fence ? `${part.endsWith("\n") ? "" : "\n"}${fence.marker}` : ""}`);
|
|
71
|
+
part = fence ? `${fence.opening}\n` : "";
|
|
72
|
+
}
|
|
73
|
+
part += chunk;
|
|
74
|
+
}
|
|
75
|
+
const marker = line.match(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)(?:\r?\n)?$/u);
|
|
76
|
+
if (!fence && marker?.[1] && marker[2] !== undefined) {
|
|
77
|
+
if (marker[1].startsWith("~") || !marker[2].includes("`"))
|
|
78
|
+
fence = { marker: marker[1], opening: line.replace(/\r?\n$/u, "") };
|
|
79
|
+
}
|
|
80
|
+
else if (fence &&
|
|
81
|
+
marker?.[1] &&
|
|
82
|
+
marker[1][0] === fence.marker[0] &&
|
|
83
|
+
marker[1].length >= fence.marker.length &&
|
|
84
|
+
!marker[2]?.trim())
|
|
85
|
+
fence = undefined;
|
|
86
|
+
}
|
|
87
|
+
if (part)
|
|
88
|
+
parts.push(part);
|
|
89
|
+
}
|
|
90
|
+
for (const [index, part] of parts.entries()) {
|
|
91
|
+
state.outbox.push({
|
|
92
|
+
id: ++replyId,
|
|
93
|
+
channelId,
|
|
94
|
+
text: parts.length > 1
|
|
95
|
+
? `(${index + 1}/${parts.length})\n${delivery.markdown ? "\n" : ""}${part}`
|
|
96
|
+
: part,
|
|
97
|
+
attempts: 0,
|
|
98
|
+
retryAt: Date.now(),
|
|
99
|
+
...delivery,
|
|
100
|
+
...(delivery.permissionMenuId ||
|
|
101
|
+
delivery.modelMenuId ||
|
|
102
|
+
delivery.sessions?.length ||
|
|
103
|
+
(delivery.interactionId && !delivery.messageId)
|
|
104
|
+
? { promptEnd: index === parts.length - 1 }
|
|
105
|
+
: {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** 附件与正文在同一状态事务中落盘,每个附件拥有独立发送回执。 */
|
|
109
|
+
for (const attachment of attachments)
|
|
110
|
+
state.outbox.push({
|
|
111
|
+
id: ++replyId,
|
|
112
|
+
channelId,
|
|
113
|
+
text: "",
|
|
114
|
+
attachment,
|
|
115
|
+
attempts: 0,
|
|
116
|
+
retryAt: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** 手动和定时输入共用任务快照,始终只允许一个未结束的编码任务。 */
|
|
120
|
+
function createTask(channel, prompt, attachments = [], cron = null) {
|
|
121
|
+
return update(() => {
|
|
122
|
+
if (state.tasks.some((task) => activeTaskStatuses.has(task.status)))
|
|
123
|
+
throw new Error("A task is already running.");
|
|
124
|
+
const task = {
|
|
125
|
+
id: state.tasks.reduce((max, item) => Math.max(max, item.id), 0) + 1,
|
|
126
|
+
channelId: channel.id,
|
|
127
|
+
cron,
|
|
128
|
+
cwd: channel.cwd,
|
|
129
|
+
sessionId: channel.sessionId,
|
|
130
|
+
runId: null,
|
|
131
|
+
prompt,
|
|
132
|
+
attachments,
|
|
133
|
+
permissionMode: channel.permissionMode,
|
|
134
|
+
model: channel.model,
|
|
135
|
+
reasoningEffort: channel.reasoningEffort,
|
|
136
|
+
inputs: [],
|
|
137
|
+
status: "queued",
|
|
138
|
+
result: null,
|
|
139
|
+
};
|
|
140
|
+
state.tasks.push(task);
|
|
141
|
+
return task;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** 定时执行固定创建时的选择,每次新建会话,不改变聊天当前会话。 */
|
|
145
|
+
const crons = createCrons({
|
|
146
|
+
read: () => state,
|
|
147
|
+
update,
|
|
148
|
+
enqueueReply,
|
|
149
|
+
createTask: (channel, cron, scheduledAt) => createTask({
|
|
150
|
+
...channel,
|
|
151
|
+
cwd: cron.cwd,
|
|
152
|
+
sessionId: null,
|
|
153
|
+
model: cron.model,
|
|
154
|
+
reasoningEffort: cron.reasoningEffort,
|
|
155
|
+
permissionMode: cron.permissionMode,
|
|
156
|
+
}, cron.prompt, [], { id: cron.id, scheduledAt }),
|
|
157
|
+
});
|
|
158
|
+
/** 菜单始终读取当前事务快照,避免捕获 update 替换前的状态。 */
|
|
159
|
+
const { closeMenu, ...menus } = createMenus({ read: () => state, update, enqueueReply });
|
|
160
|
+
/** 关闭请求或单个已答问题;撤回未发送提示,已发送消息保留正文并移除按钮。 */
|
|
161
|
+
function closeInteraction(id, reason, questionIndex) {
|
|
162
|
+
const interaction = state.interactions.find((item) => item.id === id);
|
|
163
|
+
if (!interaction || interaction.status === "closed")
|
|
164
|
+
return;
|
|
165
|
+
update(() => {
|
|
166
|
+
const interaction = state.interactions.find((item) => item.id === id);
|
|
167
|
+
const task = state.tasks.find((task) => task.id === interaction?.taskId);
|
|
168
|
+
if (!interaction || !task)
|
|
169
|
+
return;
|
|
170
|
+
if (questionIndex === undefined) {
|
|
171
|
+
interaction.status = "closed";
|
|
172
|
+
interaction.resolution = reason;
|
|
173
|
+
}
|
|
174
|
+
state.outbox = state.outbox.filter((reply) => reply.interactionId !== id ||
|
|
175
|
+
(questionIndex !== undefined && reply.questionIndex !== questionIndex));
|
|
176
|
+
for (const message of interaction.messages) {
|
|
177
|
+
if (questionIndex !== undefined && message.questionIndex !== questionIndex)
|
|
178
|
+
continue;
|
|
179
|
+
enqueueReply(task.channelId, `${message.text}\n\n${reason}`, {
|
|
180
|
+
interactionId: id,
|
|
181
|
+
messageId: message.messageId,
|
|
182
|
+
...(message.questionIndex !== undefined ? { questionIndex: message.questionIndex } : {}),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/** 终态与最终回复一起保存;定时结果标注来源,迟到事件不能覆盖已完成结果。 */
|
|
188
|
+
function finishTask(id, result) {
|
|
189
|
+
if (!state.tasks.some((task) => task.id === id && activeTaskStatuses.has(task.status)))
|
|
190
|
+
return;
|
|
191
|
+
update(() => {
|
|
192
|
+
const task = state.tasks.find((task) => task.id === id);
|
|
193
|
+
if (!task)
|
|
194
|
+
throw new Error("Task not found.");
|
|
195
|
+
Object.assign(task, { status: result.status, result: result.text });
|
|
196
|
+
for (const interaction of state.interactions.filter((item) => item.taskId === id))
|
|
197
|
+
closeInteraction(interaction.id, "Task ended. Request closed.");
|
|
198
|
+
if (result.status === "interrupted") {
|
|
199
|
+
const channel = state.channels.find((channel) => channel.id === task.channelId);
|
|
200
|
+
if (channel) {
|
|
201
|
+
channel.paused = true;
|
|
202
|
+
crons.resetCronPending(channel.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
enqueueReply(task.channelId, `${task.cron ? `Cron #${task.cron.id}\n` : ""}${result.status === "completed"
|
|
206
|
+
? result.text
|
|
207
|
+
: `Task #${id} ${{
|
|
208
|
+
failed: "Failed",
|
|
209
|
+
cancelled: "Stopped",
|
|
210
|
+
interrupted: "Unconfirmed",
|
|
211
|
+
}[result.status]}\n${result.text}`}`, { markdown: true, attachments: result.attachments ?? [] });
|
|
212
|
+
for (const [index, input] of task.inputs.entries()) {
|
|
213
|
+
if (!["pending", "preparing", "sending"].includes(input.status))
|
|
214
|
+
continue;
|
|
215
|
+
enqueueReply(task.channelId, `Task #${id}, message ${index + 1}: ${input.status === "sending" ? "delivery unconfirmed" : "not sent"}. Task ended; not retried. Check the session before resending.`);
|
|
216
|
+
input.status = "failed";
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
if (draft?.taskId === id)
|
|
220
|
+
draft = undefined;
|
|
221
|
+
}
|
|
222
|
+
/** 命令与交互按钮共用停止逻辑;先使交互失效,再由核心层请求中断。 */
|
|
223
|
+
function cancelTask(id) {
|
|
224
|
+
if (["queued", "preparing"].includes(state.tasks.find((task) => task.id === id)?.status ?? "")) {
|
|
225
|
+
finishTask(id, { status: "cancelled", text: "Cancelled before starting." });
|
|
226
|
+
}
|
|
227
|
+
else
|
|
228
|
+
update(() => {
|
|
229
|
+
const task = state.tasks.find((task) => task.id === id && activeTaskStatuses.has(task.status));
|
|
230
|
+
if (task)
|
|
231
|
+
task.status = "cancelling";
|
|
232
|
+
for (const interaction of state.interactions.filter((item) => item.taskId === id))
|
|
233
|
+
closeInteraction(interaction.id, "Stopping. Request closed.");
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
/** 去重标识、任务和必要回复合并保存,成功返回后才能确认入站事件。 */
|
|
238
|
+
accept(message, handle) {
|
|
239
|
+
const existing = state.channels.find((channel) => channel.platform === message.channel.platform &&
|
|
240
|
+
channel.accountId === message.channel.accountId &&
|
|
241
|
+
channel.chatId === message.channel.chatId);
|
|
242
|
+
if (existing &&
|
|
243
|
+
state.inboundUpdates.some((item) => item.channelId === existing.id && item.eventId === message.eventId))
|
|
244
|
+
return ["interaction", "permissions", "model", "session"].includes(message.content.type)
|
|
245
|
+
? "Already handled."
|
|
246
|
+
: undefined;
|
|
247
|
+
return update(() => {
|
|
248
|
+
let channel = state.channels.find((channel) => channel.id === existing?.id);
|
|
249
|
+
if (!channel) {
|
|
250
|
+
channel = {
|
|
251
|
+
...message.channel,
|
|
252
|
+
id: state.channels.reduce((max, item) => Math.max(max, item.id), 0) + 1,
|
|
253
|
+
/** 创建聊天时固定服务启动目录,后续命令直接使用已保存的路径。 */
|
|
254
|
+
cwd: options.cwd,
|
|
255
|
+
sessionId: null,
|
|
256
|
+
paused: false,
|
|
257
|
+
permissionMode: null,
|
|
258
|
+
permissionMenu: null,
|
|
259
|
+
model: null,
|
|
260
|
+
reasoningEffort: null,
|
|
261
|
+
modelMenu: null,
|
|
262
|
+
};
|
|
263
|
+
state.channels.push(channel);
|
|
264
|
+
}
|
|
265
|
+
channel.userId = message.channel.userId;
|
|
266
|
+
state.inboundUpdates.push({
|
|
267
|
+
channelId: channel.id,
|
|
268
|
+
eventId: message.eventId,
|
|
269
|
+
});
|
|
270
|
+
const reply = handle(channel);
|
|
271
|
+
if (!["interaction", "permissions", "model", "session"].includes(message.content.type))
|
|
272
|
+
enqueueReply(channel.id, reply);
|
|
273
|
+
return reply;
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
/** 独立只读查询完成后保存回复;写入失败交给工作循环触发关停。 */
|
|
277
|
+
reply(channelId, text, delivery = {}) {
|
|
278
|
+
update(() => {
|
|
279
|
+
if (state.channels.some((channel) => channel.id === channelId))
|
|
280
|
+
enqueueReply(channelId, text, delivery);
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
/** 只回收指定来源已确认的事件;确认规则由连接器提供,不解释事件编号。 */
|
|
284
|
+
confirmUpdates(source, confirmed) {
|
|
285
|
+
const pending = state.inboundUpdates.filter((item) => {
|
|
286
|
+
const channel = state.channels.find((channel) => channel.id === item.channelId);
|
|
287
|
+
return (channel?.platform !== source.platform ||
|
|
288
|
+
channel.accountId !== source.accountId ||
|
|
289
|
+
!confirmed(item.eventId));
|
|
290
|
+
});
|
|
291
|
+
if (pending.length !== state.inboundUpdates.length)
|
|
292
|
+
update(() => {
|
|
293
|
+
state.inboundUpdates = pending;
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
/** 新会话或目录变化只影响后续任务,已保存任务仍使用自己的快照。 */
|
|
297
|
+
setSession(channelId, cwd, sessionId, reply) {
|
|
298
|
+
update(() => {
|
|
299
|
+
const channel = state.channels.find((channel) => channel.id === channelId);
|
|
300
|
+
if (channel) {
|
|
301
|
+
closeMenu(channelId, "permissionMenu");
|
|
302
|
+
closeMenu(channelId, "modelMenu");
|
|
303
|
+
if (cwd !== channel.cwd) {
|
|
304
|
+
channel.permissionMode = null;
|
|
305
|
+
channel.model = null;
|
|
306
|
+
channel.reasoningEffort = null;
|
|
307
|
+
}
|
|
308
|
+
Object.assign(channel, { cwd, sessionId });
|
|
309
|
+
/** 会话选择与成功回复一同落盘;暂停状态以接口返回时的值为准。 */
|
|
310
|
+
if (reply)
|
|
311
|
+
enqueueReply(channelId, `${reply}${channel.paused ? "\nPaused. Send /resume to continue." : ""}`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
...menus,
|
|
316
|
+
...crons,
|
|
317
|
+
/** 暂停状态跨重启保留,恢复须由用户明确触发。 */
|
|
318
|
+
setPaused(channelId, paused) {
|
|
319
|
+
update(() => {
|
|
320
|
+
const channel = state.channels.find((channel) => channel.id === channelId);
|
|
321
|
+
if (channel) {
|
|
322
|
+
const wasPaused = channel.paused;
|
|
323
|
+
channel.paused = paused;
|
|
324
|
+
if (paused || wasPaused)
|
|
325
|
+
crons.resetCronPending(channelId);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
/** 返回任务快照;连接器查询时限定来源,避免向其他账号显示活动状态。 */
|
|
330
|
+
activeTask(source) {
|
|
331
|
+
const task = state.tasks.find((task) => activeTaskStatuses.has(task.status) &&
|
|
332
|
+
(!source ||
|
|
333
|
+
state.channels.some((channel) => channel.id === task.channelId &&
|
|
334
|
+
channel.platform === source.platform &&
|
|
335
|
+
channel.accountId === source.accountId &&
|
|
336
|
+
channel.userId === source.userId)));
|
|
337
|
+
return task
|
|
338
|
+
? structuredClone({
|
|
339
|
+
...task,
|
|
340
|
+
interactions: state.interactions.filter((item) => item.taskId === task.id && item.status !== "closed"),
|
|
341
|
+
})
|
|
342
|
+
: undefined;
|
|
343
|
+
},
|
|
344
|
+
/** 内存预览保留完整内容;IM 决定能否展示草稿,正式结果始终完整分段发送。 */
|
|
345
|
+
setDraft(id, text) {
|
|
346
|
+
if (state.tasks.some((task) => task.id === id && task.status === "running"))
|
|
347
|
+
draft = { taskId: id, text };
|
|
348
|
+
},
|
|
349
|
+
/** 仅向当前机器人与授权用户返回执行中的草稿,停止后不再刷新。 */
|
|
350
|
+
pendingDraft(source) {
|
|
351
|
+
if (!draft?.text.trim())
|
|
352
|
+
return;
|
|
353
|
+
if (state.interactions.some((item) => item.taskId === draft?.taskId && item.blocking && item.status !== "closed"))
|
|
354
|
+
return;
|
|
355
|
+
const task = state.tasks.find((task) => task.id === draft?.taskId && task.status === "running");
|
|
356
|
+
const channel = state.channels.find((channel) => channel.id === task?.channelId);
|
|
357
|
+
if (channel?.platform === source.platform &&
|
|
358
|
+
channel.accountId === source.accountId &&
|
|
359
|
+
channel.userId === source.userId)
|
|
360
|
+
return { ...draft, chatId: channel.chatId };
|
|
361
|
+
},
|
|
362
|
+
/** 最近结果按聊天隔离,供 /last 再次查看。 */
|
|
363
|
+
lastTask(channelId) {
|
|
364
|
+
return structuredClone(state.tasks.findLast((task) => task.channelId === channelId && !activeTaskStatuses.has(task.status)) ?? state.tasks.findLast((task) => task.channelId === channelId));
|
|
365
|
+
},
|
|
366
|
+
createTask,
|
|
367
|
+
/** 尚未派发时合并首条输入;执行中的补充输入与入站去重标识一起保存。 */
|
|
368
|
+
appendTaskInput(id, text, attachments = []) {
|
|
369
|
+
update(() => {
|
|
370
|
+
const task = state.tasks.find((task) => task.id === id);
|
|
371
|
+
if (!task || !["queued", "preparing", "dispatching", "running"].includes(task.status))
|
|
372
|
+
throw new Error("Task cannot accept more input.");
|
|
373
|
+
if (task.status === "queued") {
|
|
374
|
+
task.prompt += `\n\n${text}`;
|
|
375
|
+
task.attachments.push(...attachments);
|
|
376
|
+
}
|
|
377
|
+
else
|
|
378
|
+
task.inputs.push({ text, attachments, status: "pending" });
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
/** 按接收顺序准备附件;下载阶段尚未提交给 Agent,可以在重启后继续。 */
|
|
382
|
+
claimTaskInput(id) {
|
|
383
|
+
const task = state.tasks.find((task) => task.id === id && task.status === "running");
|
|
384
|
+
const index = task?.inputs.findIndex((input) => input.status === "pending") ?? -1;
|
|
385
|
+
if (!task || index < 0)
|
|
386
|
+
return;
|
|
387
|
+
return update(() => {
|
|
388
|
+
const input = state.tasks.find((task) => task.id === id)?.inputs[index];
|
|
389
|
+
if (!input)
|
|
390
|
+
throw new Error("Input not found.");
|
|
391
|
+
input.status = "preparing";
|
|
392
|
+
return { index, text: input.text, attachments: input.attachments };
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
/** 附件准备完成后再次核实任务仍在执行,先保存 sending 再提交,避免误入其他轮次。 */
|
|
396
|
+
markTaskInputSending(id, index) {
|
|
397
|
+
const task = state.tasks.find((task) => task.id === id && task.status === "running");
|
|
398
|
+
if (task?.inputs[index]?.status !== "preparing")
|
|
399
|
+
return false;
|
|
400
|
+
return update(() => {
|
|
401
|
+
const input = state.tasks.find((task) => task.id === id)?.inputs[index];
|
|
402
|
+
if (!input)
|
|
403
|
+
return false;
|
|
404
|
+
input.status = "sending";
|
|
405
|
+
return true;
|
|
406
|
+
});
|
|
407
|
+
},
|
|
408
|
+
/** 成功补充不产生固定回执,失败提示和输入状态原子保存。 */
|
|
409
|
+
finishTaskInput(id, index, error) {
|
|
410
|
+
update(() => {
|
|
411
|
+
const task = state.tasks.find((task) => task.id === id);
|
|
412
|
+
const input = task?.inputs[index];
|
|
413
|
+
if (!task || !input || !["preparing", "sending"].includes(input.status))
|
|
414
|
+
return;
|
|
415
|
+
input.status = error === undefined ? "accepted" : "failed";
|
|
416
|
+
if (error !== undefined)
|
|
417
|
+
enqueueReply(task.channelId, `Task #${id}, message ${index + 1}: ${error}`);
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
/** 领取新任务或已保存轮次;恢复只连接后台,不能重发原始输入。 */
|
|
421
|
+
claimTask(source) {
|
|
422
|
+
const index = state.tasks.findIndex((task) => activeTaskStatuses.has(task.status));
|
|
423
|
+
const task = state.tasks[index];
|
|
424
|
+
const channel = state.channels.find((channel) => channel.id === task?.channelId);
|
|
425
|
+
if (!task ||
|
|
426
|
+
!channel ||
|
|
427
|
+
(channel.paused && !task.runId) ||
|
|
428
|
+
channel.platform !== source.platform ||
|
|
429
|
+
channel.accountId !== source.accountId ||
|
|
430
|
+
channel.userId !== source.userId)
|
|
431
|
+
return;
|
|
432
|
+
return update(() => {
|
|
433
|
+
const claimed = {
|
|
434
|
+
...task,
|
|
435
|
+
status: task.status === "cancelling"
|
|
436
|
+
? "cancelling"
|
|
437
|
+
: !task.runId && task.attachments.length
|
|
438
|
+
? "preparing"
|
|
439
|
+
: "dispatching",
|
|
440
|
+
};
|
|
441
|
+
state.tasks[index] = claimed;
|
|
442
|
+
return claimed;
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
/** 下载完成后才进入可能派发的阶段;已停止的任务不能因下载迟到而启动。 */
|
|
446
|
+
markDispatching(id) {
|
|
447
|
+
if (!state.tasks.some((task) => task.id === id && task.status === "preparing"))
|
|
448
|
+
return false;
|
|
449
|
+
return update(() => {
|
|
450
|
+
const task = state.tasks.find((task) => task.id === id);
|
|
451
|
+
if (!task)
|
|
452
|
+
return false;
|
|
453
|
+
task.status = "dispatching";
|
|
454
|
+
return true;
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
/** 新建会话后立即保存关联;定时任务只更新执行快照,保留手动聊天的上下文。 */
|
|
458
|
+
recordSession(id, sessionId) {
|
|
459
|
+
update(() => {
|
|
460
|
+
const task = state.tasks.find((task) => task.id === id && activeTaskStatuses.has(task.status));
|
|
461
|
+
if (!task)
|
|
462
|
+
return;
|
|
463
|
+
task.sessionId = sessionId;
|
|
464
|
+
const channel = state.channels.find((channel) => channel.id === task.channelId);
|
|
465
|
+
if (channel && !task.cron)
|
|
466
|
+
channel.sessionId = sessionId;
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
/** 轮次 ID 与执行状态一起落盘;迟到通知不能撤销停止请求。 */
|
|
470
|
+
markRunning(id, runId) {
|
|
471
|
+
update(() => {
|
|
472
|
+
const task = state.tasks.find((task) => task.id === id);
|
|
473
|
+
if (!task || !activeTaskStatuses.has(task.status))
|
|
474
|
+
return;
|
|
475
|
+
if (task.runId && task.runId !== runId)
|
|
476
|
+
throw new Error("Task turn changed.");
|
|
477
|
+
task.runId = runId;
|
|
478
|
+
if (task.status === "dispatching")
|
|
479
|
+
task.status = "running";
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
cancelTask,
|
|
483
|
+
finishTask,
|
|
484
|
+
/** 交互展示与任务关联一起保存;多题分别发送,任意一段都能明确回复。 */
|
|
485
|
+
addInteraction(taskId, request) {
|
|
486
|
+
update(() => {
|
|
487
|
+
const task = state.tasks.find((task) => task.id === taskId && task.status === "running");
|
|
488
|
+
if (!task || state.interactions.some((item) => item.id === request.id))
|
|
489
|
+
return;
|
|
490
|
+
state.interactions.push({
|
|
491
|
+
...request,
|
|
492
|
+
taskId,
|
|
493
|
+
questions: request.questions.map((question) => ({ ...question, answer: null })),
|
|
494
|
+
decision: null,
|
|
495
|
+
status: "pending",
|
|
496
|
+
resolution: null,
|
|
497
|
+
messages: [],
|
|
498
|
+
});
|
|
499
|
+
if (request.kind === "approval") {
|
|
500
|
+
enqueueReply(task.channelId, `Task #${taskId} · Approval\n${request.text}\n\nChoose below.`, { interactionId: request.id });
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
for (const [index, question] of request.questions.entries())
|
|
504
|
+
enqueueReply(task.channelId, `Task #${taskId} · Question ${index + 1}/${request.questions.length}\n${question.header}\n${question.question}${question.options.map((option, i) => `\n\n${i + 1}. ${option.label}\n${option.description}`).join("")}\n\n${question.allowText ? "Pick an option or reply to this message." : "Pick an option or reply with its number or label."}\nAnswer all questions to submit.`, { interactionId: request.id, questionIndex: index });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
/** 与入站去重同一事务领取选择;不把普通消息或旧问题回复误作当前任务输入。 */
|
|
509
|
+
answerInteraction(channelId, content) {
|
|
510
|
+
const interaction = state.interactions.find((item) => content.type === "interaction"
|
|
511
|
+
? item.id === content.id
|
|
512
|
+
: item.messages.some((message) => message.messageId === content.replyToMessageId) &&
|
|
513
|
+
state.tasks.some((task) => task.id === item.taskId && task.channelId === channelId));
|
|
514
|
+
if (!interaction)
|
|
515
|
+
return content.type === "interaction"
|
|
516
|
+
? "Request expired."
|
|
517
|
+
: content.replyToMessageId !== undefined
|
|
518
|
+
? "Send a new message to continue."
|
|
519
|
+
: undefined;
|
|
520
|
+
const task = state.tasks.find((task) => task.id === interaction.taskId && task.channelId === channelId);
|
|
521
|
+
const message = interaction.messages.find((item) => item.messageId ===
|
|
522
|
+
(content.type === "interaction" ? content.messageId : content.replyToMessageId));
|
|
523
|
+
if (task?.status !== "running" ||
|
|
524
|
+
!message ||
|
|
525
|
+
interaction.status !== "pending" ||
|
|
526
|
+
(interaction.expiresAt !== null && interaction.expiresAt <= Date.now()))
|
|
527
|
+
return "Request closed. Check /status.";
|
|
528
|
+
/** 只有问题卡片提供停止操作,审批卡片仅接受审批选项。 */
|
|
529
|
+
if (interaction.kind === "question" &&
|
|
530
|
+
content.type === "interaction" &&
|
|
531
|
+
content.action === "stop") {
|
|
532
|
+
update(() => {
|
|
533
|
+
const channel = state.channels.find((channel) => channel.id === channelId);
|
|
534
|
+
if (channel) {
|
|
535
|
+
channel.paused = true;
|
|
536
|
+
crons.resetCronPending(channel.id);
|
|
537
|
+
}
|
|
538
|
+
cancelTask(task.id);
|
|
539
|
+
});
|
|
540
|
+
return "Stopping. Wait for the result.";
|
|
541
|
+
}
|
|
542
|
+
if (interaction.kind === "approval") {
|
|
543
|
+
if (content.type !== "interaction" ||
|
|
544
|
+
content.action !== "approval" ||
|
|
545
|
+
!Number.isInteger(content.optionIndex))
|
|
546
|
+
return "Use the approval buttons.";
|
|
547
|
+
const choice = interaction.choices[content.optionIndex ?? -1];
|
|
548
|
+
if (!choice)
|
|
549
|
+
return "Invalid approval option.";
|
|
550
|
+
update(() => {
|
|
551
|
+
const current = state.interactions.find((item) => item.id === interaction.id);
|
|
552
|
+
if (!current)
|
|
553
|
+
return;
|
|
554
|
+
current.decision = choice.id;
|
|
555
|
+
current.status = "ready";
|
|
556
|
+
});
|
|
557
|
+
return "Saved. Submitting...";
|
|
558
|
+
}
|
|
559
|
+
const question = message.questionIndex === undefined
|
|
560
|
+
? undefined
|
|
561
|
+
: interaction.questions[message.questionIndex];
|
|
562
|
+
if (!question || question.answer !== null)
|
|
563
|
+
return "Question closed.";
|
|
564
|
+
let answer;
|
|
565
|
+
if (content.type === "interaction") {
|
|
566
|
+
if (content.action !== "option" ||
|
|
567
|
+
content.questionIndex !== message.questionIndex ||
|
|
568
|
+
content.optionIndex === undefined)
|
|
569
|
+
return "Invalid option.";
|
|
570
|
+
answer = question.options[content.optionIndex]?.label;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
answer = content.text.trim();
|
|
574
|
+
answer =
|
|
575
|
+
question.options.find((option, index) => String(index + 1) === answer || option.label === answer)?.label ?? (question.allowText ? answer : undefined);
|
|
576
|
+
}
|
|
577
|
+
if (!answer)
|
|
578
|
+
return "Invalid answer. Try again.";
|
|
579
|
+
update(() => {
|
|
580
|
+
const current = state.interactions.find((item) => item.id === interaction.id);
|
|
581
|
+
const question = current?.questions[message.questionIndex ?? -1];
|
|
582
|
+
if (!current || !question)
|
|
583
|
+
return;
|
|
584
|
+
question.answer = answer;
|
|
585
|
+
closeInteraction(interaction.id, "Answer saved.", message.questionIndex);
|
|
586
|
+
if (current.questions.every((question) => question.answer !== null))
|
|
587
|
+
current.status = "ready";
|
|
588
|
+
});
|
|
589
|
+
return "Answer saved.";
|
|
590
|
+
},
|
|
591
|
+
/** 提交前持久化 sending;重启不会重放已经领取的批准或答案。 */
|
|
592
|
+
claimInteraction(taskId) {
|
|
593
|
+
const interaction = state.interactions.find((item) => item.taskId === taskId &&
|
|
594
|
+
item.status === "ready" &&
|
|
595
|
+
(item.expiresAt === null || item.expiresAt > Date.now()));
|
|
596
|
+
if (!interaction ||
|
|
597
|
+
!state.tasks.some((task) => task.id === taskId && task.status === "running"))
|
|
598
|
+
return;
|
|
599
|
+
return update(() => {
|
|
600
|
+
const current = state.interactions.find((item) => item.id === interaction.id);
|
|
601
|
+
if (!current)
|
|
602
|
+
return;
|
|
603
|
+
current.status = "sending";
|
|
604
|
+
const answer = current.kind === "approval" && current.decision
|
|
605
|
+
? { decision: current.decision }
|
|
606
|
+
: {
|
|
607
|
+
answers: Object.fromEntries(current.questions.map((question) => [
|
|
608
|
+
question.id,
|
|
609
|
+
{ answers: [question.answer ?? ""] },
|
|
610
|
+
])),
|
|
611
|
+
};
|
|
612
|
+
return { id: current.id, answer };
|
|
613
|
+
});
|
|
614
|
+
},
|
|
615
|
+
closeInteraction,
|
|
616
|
+
/** 有轮次 ID 的任务重新连接;未确认派发和在途输入不自动重放。 */
|
|
617
|
+
recoverTasks() {
|
|
618
|
+
if (state.channels.some((channel) => channel.permissionMenu || channel.modelMenu))
|
|
619
|
+
update(() => {
|
|
620
|
+
for (const channel of state.channels) {
|
|
621
|
+
closeMenu(channel.id, "permissionMenu");
|
|
622
|
+
closeMenu(channel.id, "modelMenu");
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
for (const task of state.tasks.filter((task) => activeTaskStatuses.has(task.status) && task.status !== "queued")) {
|
|
626
|
+
/** 下载阶段从未提交给 Codex;主输入和补充输入都可以安全重新准备。 */
|
|
627
|
+
if (task.status === "preparing" ||
|
|
628
|
+
task.inputs.some((input) => input.status === "preparing"))
|
|
629
|
+
update(() => {
|
|
630
|
+
const current = state.tasks.find((item) => item.id === task.id);
|
|
631
|
+
if (!current)
|
|
632
|
+
return;
|
|
633
|
+
if (current.status === "preparing")
|
|
634
|
+
current.status = "queued";
|
|
635
|
+
for (const input of current.inputs)
|
|
636
|
+
if (input.status === "preparing")
|
|
637
|
+
input.status = "pending";
|
|
638
|
+
});
|
|
639
|
+
if (task.status === "preparing")
|
|
640
|
+
continue;
|
|
641
|
+
if (task.sessionId && task.runId) {
|
|
642
|
+
for (const [index, input] of task.inputs.entries()) {
|
|
643
|
+
if (input.status !== "sending")
|
|
644
|
+
continue;
|
|
645
|
+
update(() => {
|
|
646
|
+
const current = state.tasks.find((item) => item.id === task.id)?.inputs[index];
|
|
647
|
+
if (current)
|
|
648
|
+
current.status = "failed";
|
|
649
|
+
enqueueReply(task.channelId, `Task #${task.id}, message ${index + 1}: delivery unconfirmed after restart. Not retried; check the session before resending.`);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
finishTask(task.id, {
|
|
655
|
+
status: "interrupted",
|
|
656
|
+
text: "Service restarted; task result unknown. Check the project, then /resume and send a new task.",
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
for (const interaction of state.interactions)
|
|
660
|
+
closeInteraction(interaction.id, "Service restarted. Request closed.");
|
|
661
|
+
},
|
|
662
|
+
/** 按机器人与授权用户领取最早回复,内存关联保留到发送成功或失败。 */
|
|
663
|
+
claimReply(source) {
|
|
664
|
+
if (delivering)
|
|
665
|
+
return;
|
|
666
|
+
for (const channel of state.channels) {
|
|
667
|
+
if (channel.permissionMenu && channel.permissionMenu.expiresAt <= Date.now())
|
|
668
|
+
update(() => closeMenu(channel.id, "permissionMenu"));
|
|
669
|
+
if (channel.modelMenu && channel.modelMenu.expiresAt <= Date.now())
|
|
670
|
+
update(() => closeMenu(channel.id, "modelMenu"));
|
|
671
|
+
}
|
|
672
|
+
for (const reply of state.outbox) {
|
|
673
|
+
const interaction = state.interactions.find((item) => item.id === reply.interactionId);
|
|
674
|
+
if (reply.interactionId &&
|
|
675
|
+
!reply.messageId &&
|
|
676
|
+
(interaction?.status !== "pending" ||
|
|
677
|
+
(interaction.expiresAt !== null && interaction.expiresAt <= Date.now()) ||
|
|
678
|
+
(reply.questionIndex !== undefined &&
|
|
679
|
+
interaction.questions[reply.questionIndex]?.answer !== null)))
|
|
680
|
+
continue;
|
|
681
|
+
const channel = state.channels.find((channel) => channel.id === reply.channelId);
|
|
682
|
+
if (channel?.platform === source.platform &&
|
|
683
|
+
channel.accountId === source.accountId &&
|
|
684
|
+
channel.userId === source.userId) {
|
|
685
|
+
if (reply.retryAt > Date.now())
|
|
686
|
+
return;
|
|
687
|
+
delivering = structuredClone(reply);
|
|
688
|
+
return structuredClone({
|
|
689
|
+
...reply,
|
|
690
|
+
chatId: channel.chatId,
|
|
691
|
+
interaction,
|
|
692
|
+
permissionMenu: channel.permissionMenu?.id === reply.permissionMenuId
|
|
693
|
+
? channel.permissionMenu
|
|
694
|
+
: undefined,
|
|
695
|
+
modelMenu: channel.modelMenu?.id === reply.modelMenuId ? channel.modelMenu : undefined,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
/** 发送成功或永久拒绝后移出队列;/last 只保留每个聊天的最近结果。 */
|
|
701
|
+
removeReply(id, delivery) {
|
|
702
|
+
update(() => {
|
|
703
|
+
const channelId = delivering?.id === id
|
|
704
|
+
? delivering.channelId
|
|
705
|
+
: state.outbox.find((reply) => reply.id === id)?.channelId;
|
|
706
|
+
if (delivering?.id === id)
|
|
707
|
+
delivering = undefined;
|
|
708
|
+
state.outbox = state.outbox.filter((reply) => reply.id !== id);
|
|
709
|
+
if ((delivery?.permissionMenuId || delivery?.modelMenuId) && channelId !== undefined) {
|
|
710
|
+
const channel = state.channels.find((item) => item.id === channelId);
|
|
711
|
+
const menu = delivery.modelMenuId ? channel?.modelMenu : channel?.permissionMenu;
|
|
712
|
+
if (menu &&
|
|
713
|
+
menu.id === (delivery.modelMenuId ?? delivery.permissionMenuId) &&
|
|
714
|
+
menu.expiresAt > Date.now()) {
|
|
715
|
+
if (!menu.messages.some((message) => message.messageId === delivery.messageId))
|
|
716
|
+
menu.messages.push({
|
|
717
|
+
messageId: delivery.messageId,
|
|
718
|
+
text: delivery.text,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
else if (
|
|
722
|
+
/** 迟到的编辑回执不能再排入旧正文,覆盖已保存的新阶段或最终确认。 */
|
|
723
|
+
!state.outbox.some((reply) => reply.channelId === channelId && reply.messageId === delivery.messageId))
|
|
724
|
+
enqueueReply(channelId, delivery.text, { messageId: delivery.messageId });
|
|
725
|
+
}
|
|
726
|
+
const interaction = state.interactions.find((item) => item.id === delivery?.interactionId);
|
|
727
|
+
const task = state.tasks.find((task) => task.id === interaction?.taskId);
|
|
728
|
+
if (interaction && task && delivery) {
|
|
729
|
+
interaction.messages.push({
|
|
730
|
+
messageId: delivery.messageId,
|
|
731
|
+
text: delivery.text,
|
|
732
|
+
...(delivery.questionIndex !== undefined
|
|
733
|
+
? { questionIndex: delivery.questionIndex }
|
|
734
|
+
: {}),
|
|
735
|
+
});
|
|
736
|
+
/** 发送途中请求可能已关闭或该问题已回答;迟到的消息也必须移除按钮。 */
|
|
737
|
+
if (interaction.status !== "pending" ||
|
|
738
|
+
(interaction.expiresAt !== null && interaction.expiresAt <= Date.now()) ||
|
|
739
|
+
(delivery.questionIndex !== undefined &&
|
|
740
|
+
interaction.questions[delivery.questionIndex]?.answer !== null))
|
|
741
|
+
enqueueReply(task.channelId, `${delivery.text}\n\n${interaction.resolution ?? "Request closed."}`, {
|
|
742
|
+
interactionId: interaction.id,
|
|
743
|
+
messageId: delivery.messageId,
|
|
744
|
+
...(delivery.questionIndex !== undefined
|
|
745
|
+
? { questionIndex: delivery.questionIndex }
|
|
746
|
+
: {}),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
},
|
|
751
|
+
/** 发送失败只更新回复的重试信息,不影响任务执行状态。 */
|
|
752
|
+
retryReply(id, seconds) {
|
|
753
|
+
update(() => {
|
|
754
|
+
if (delivering?.id === id)
|
|
755
|
+
delivering = undefined;
|
|
756
|
+
const reply = state.outbox.find((reply) => reply.id === id);
|
|
757
|
+
if (reply)
|
|
758
|
+
Object.assign(reply, {
|
|
759
|
+
attempts: reply.attempts + 1,
|
|
760
|
+
retryAt: Date.now() + seconds * 1000,
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
//# sourceMappingURL=state.js.map
|