@sh1robana/dsh-plugin-message-edit 0.0.0-stage → 0.3.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/CHANGELOG.md +38 -0
- package/LICENSE +23 -0
- package/NOTICE.md +24 -0
- package/README.md +66 -2
- package/build-info.json +10 -0
- package/client.js +2818 -0
- package/client.js.map +1 -0
- package/cordis.patch.yml +3 -0
- package/docs/screenshots/editor-dark.jpg +0 -0
- package/docs/screenshots/editor-light.jpg +0 -0
- package/docs//345/217/221/345/270/203/346/214/207/345/215/227.md +77 -0
- package/index.mjs +1220 -0
- package/package.json +141 -6
package/index.mjs
ADDED
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { foldSurface } from "@deepseek-ai/dsh-session";
|
|
3
|
+
import { createSystemMessage } from "@deepseek-ai/dsh-llm";
|
|
4
|
+
//#region src/saved-user-messages.ts
|
|
5
|
+
function savedUserMessageSeq(event) {
|
|
6
|
+
if (event.type !== "user/message" || typeof event.surfaceOp !== "object" || event.surfaceOp.op !== "replace") return void 0;
|
|
7
|
+
const original = event.data.source.messageEdit?.originalEventSeq;
|
|
8
|
+
return Number.isSafeInteger(original) && original >= 0 ? original : void 0;
|
|
9
|
+
}
|
|
10
|
+
/** 为聊天和历史操作还原用户可见文本;官方替换事件本身不增加聊天行。 */
|
|
11
|
+
function savedUserMessages(events) {
|
|
12
|
+
const result = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const event of events) {
|
|
14
|
+
const originalSeq = savedUserMessageSeq(event);
|
|
15
|
+
if (originalSeq === void 0 || event.type !== "user/message") continue;
|
|
16
|
+
const original = events[originalSeq];
|
|
17
|
+
if (original?.type !== "user/message" || original.surfaceOp !== "append" || original.data.id !== event.data.id) continue;
|
|
18
|
+
result.set(originalSeq, event);
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
function visibleUserEvents(events) {
|
|
23
|
+
const saved = savedUserMessages(events);
|
|
24
|
+
if (saved.size === 0) return events;
|
|
25
|
+
return events.map((event) => {
|
|
26
|
+
const replacement = saved.get(event.seq);
|
|
27
|
+
return replacement === void 0 ? event : {
|
|
28
|
+
...event,
|
|
29
|
+
data: replacement.data
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/build-info.ts
|
|
35
|
+
/** 由构建脚本生成;前后端共享同一份构建身份。 */
|
|
36
|
+
const MESSAGE_EDIT_BUILD_INFO = Object.freeze({
|
|
37
|
+
"version": "0.3.2",
|
|
38
|
+
"buildId": "dd2956d22ba2bccbd0c2",
|
|
39
|
+
"targetDshVersion": "0.2.1-rc.2"
|
|
40
|
+
});
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/shared.ts
|
|
43
|
+
/** Same-origin endpoint owned by the Message Edit host plugin. */
|
|
44
|
+
const MESSAGE_EDIT_PATH = "/api/message-edit";
|
|
45
|
+
/** Timeline sits between Trajectory (10) and Prompt Studio (20). */
|
|
46
|
+
const MESSAGE_EDIT_VIEW_ORDER = 15;
|
|
47
|
+
/** Current durable event schema for structurally paired version effects. */
|
|
48
|
+
const MESSAGE_EDIT_VERSION_SCHEMA = 2;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/index.ts
|
|
51
|
+
/** Host half of Message Edit: turn-atomic forks and structurally reversible versions. */
|
|
52
|
+
/** Stable Cordis plugin name. */
|
|
53
|
+
const name = "message-edit";
|
|
54
|
+
/** Public services used by the branch transaction and timeline projection. */
|
|
55
|
+
const inject = [
|
|
56
|
+
"sessions",
|
|
57
|
+
"agents",
|
|
58
|
+
"sessionPersistence",
|
|
59
|
+
"sessionQuery",
|
|
60
|
+
"workspaceRegistry",
|
|
61
|
+
"connection"
|
|
62
|
+
];
|
|
63
|
+
function pairVersionEffect(sourceSessionId, effect) {
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 2,
|
|
66
|
+
effect: {
|
|
67
|
+
...effect,
|
|
68
|
+
id: crypto.randomUUID()
|
|
69
|
+
},
|
|
70
|
+
inverse: {
|
|
71
|
+
kind: "restore-version",
|
|
72
|
+
sessionId: sourceSessionId
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function isTextualBlock(block) {
|
|
77
|
+
return block?.type === "text" || block?.type === "reasoning";
|
|
78
|
+
}
|
|
79
|
+
function userText(message) {
|
|
80
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
81
|
+
}
|
|
82
|
+
function cloneUser(message, content = structuredClone(message.content)) {
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
id: crypto.randomUUID(),
|
|
85
|
+
role: "user",
|
|
86
|
+
content: Object.freeze(content),
|
|
87
|
+
source: Object.freeze({ kind: "user" })
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function replaceTextBlock(content, blockIndex, text) {
|
|
91
|
+
const block = content[blockIndex];
|
|
92
|
+
if (!isTextualBlock(block)) throw new Error("所选内容块不是可编辑文本。");
|
|
93
|
+
return content.map((candidate, index) => index === blockIndex ? {
|
|
94
|
+
...candidate,
|
|
95
|
+
text
|
|
96
|
+
} : structuredClone(candidate));
|
|
97
|
+
}
|
|
98
|
+
function isAttachmentBlock(block) {
|
|
99
|
+
return block?.type === "image" || block?.type === "file";
|
|
100
|
+
}
|
|
101
|
+
/** 比较完整持久值,数组顺序有意义,对象属性顺序不构成修改。 */
|
|
102
|
+
function equalContentValue(left, right) {
|
|
103
|
+
if (left === right) return true;
|
|
104
|
+
if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false;
|
|
105
|
+
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => equalContentValue(value, right[index]));
|
|
106
|
+
const a = left;
|
|
107
|
+
const b = right;
|
|
108
|
+
const keys = Object.keys(a);
|
|
109
|
+
return keys.length === Object.keys(b).length && keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && equalContentValue(a[key], b[key]));
|
|
110
|
+
}
|
|
111
|
+
function replaceUserText(content, blockIndex, text) {
|
|
112
|
+
if (content[blockIndex]?.type === "text") return replaceTextBlock(content, blockIndex, text);
|
|
113
|
+
if (blockIndex === content.length && !content.some((block) => block.type === "text")) {
|
|
114
|
+
if (text === "") return structuredClone(content);
|
|
115
|
+
return [...structuredClone(content), {
|
|
116
|
+
type: "text",
|
|
117
|
+
text
|
|
118
|
+
}];
|
|
119
|
+
}
|
|
120
|
+
throw new Error("所选用户消息块不是文本。");
|
|
121
|
+
}
|
|
122
|
+
function replaceUserAttachments(content, attachments) {
|
|
123
|
+
const result = [];
|
|
124
|
+
const lastAttachment = content.findLastIndex(isAttachmentBlock);
|
|
125
|
+
let next = 0;
|
|
126
|
+
for (const [index, block] of content.entries()) {
|
|
127
|
+
if (!isAttachmentBlock(block)) result.push(structuredClone(block));
|
|
128
|
+
else if (next < attachments.length) result.push(structuredClone(attachments[next++]));
|
|
129
|
+
if (index === lastAttachment) {
|
|
130
|
+
result.push(...structuredClone(attachments.slice(next)));
|
|
131
|
+
next = attachments.length;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (lastAttachment < 0) result.push(...structuredClone(attachments));
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
/** 先完成官方附件准入,再写消息;拒绝或上传失败不会留下半条编辑记录。 */
|
|
138
|
+
async function withEditedUserContent(ctx, agent, message, operation, applyContent) {
|
|
139
|
+
let content = replaceUserText(message.content, operation.blockIndex, operation.text);
|
|
140
|
+
const inputs = operation.attachments;
|
|
141
|
+
const receiptIds = [];
|
|
142
|
+
let fileUploads;
|
|
143
|
+
if (inputs !== void 0) {
|
|
144
|
+
const retained = /* @__PURE__ */ new Map();
|
|
145
|
+
const admission = [];
|
|
146
|
+
for (const [index, input] of inputs.entries()) if (input.type === "retained") {
|
|
147
|
+
const block = message.content[input.blockIndex];
|
|
148
|
+
if (!isAttachmentBlock(block)) throw new TypeError("保留的附件位置不存在或不是附件。");
|
|
149
|
+
retained.set(index, structuredClone(block));
|
|
150
|
+
} else if (input.type === "file") {
|
|
151
|
+
fileUploads ??= ctx.get("fileUploads");
|
|
152
|
+
if (fileUploads === void 0) throw new Error("宿主未提供文件上传服务。");
|
|
153
|
+
const receiptId = input.receiptId;
|
|
154
|
+
const attachment = fileUploads.resolve(agent, receiptId);
|
|
155
|
+
if (attachment === void 0) throw new TypeError("文件上传凭据不存在、已失效或不属于当前会话。");
|
|
156
|
+
admission.push({
|
|
157
|
+
type: "file",
|
|
158
|
+
attachment
|
|
159
|
+
});
|
|
160
|
+
if (!receiptIds.includes(receiptId)) receiptIds.push(receiptId);
|
|
161
|
+
} else admission.push(input);
|
|
162
|
+
const store = ctx.get("attachments");
|
|
163
|
+
if (admission.length > 0 && store === void 0) throw new Error("宿主未提供附件存储服务。");
|
|
164
|
+
const admitted = admission.length === 0 ? [] : await store.admitPromptContent(admission);
|
|
165
|
+
let next = 0;
|
|
166
|
+
const attachments = inputs.map((_input, index) => retained.get(index) ?? admitted[next++]);
|
|
167
|
+
content = replaceUserAttachments(content, attachments);
|
|
168
|
+
const images = attachments.filter((block) => block.type === "image");
|
|
169
|
+
if (!equalContentValue(content, message.content) && store !== void 0 && (images.length > store.imageLimits.maxImagesPerMessage || images.reduce((bytes, block) => bytes + block.attachment.bytes, 0) > store.imageLimits.maxMessageImageBytes)) throw new Error("编辑后的图片数量或总大小超过宿主限制。");
|
|
170
|
+
}
|
|
171
|
+
if (!content.some((block) => isAttachmentBlock(block) || block.type === "text" && block.text.trim().length > 0)) throw new TypeError("用户消息需要正文或至少一个附件。");
|
|
172
|
+
const requestId = crypto.randomUUID();
|
|
173
|
+
const binding = receiptIds.length === 0 ? void 0 : fileUploads?.bindPrompt(agent, receiptIds, requestId);
|
|
174
|
+
try {
|
|
175
|
+
const result = await applyContent(content);
|
|
176
|
+
binding?.commit();
|
|
177
|
+
if (binding !== void 0) fileUploads?.retirePrompt(agent, requestId);
|
|
178
|
+
return result;
|
|
179
|
+
} finally {
|
|
180
|
+
binding?.[Symbol.dispose]();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Fold complete turn brackets; an open tail is deliberately absent. */
|
|
184
|
+
function closedTurns(events) {
|
|
185
|
+
const result = [];
|
|
186
|
+
let current;
|
|
187
|
+
for (const event of events) {
|
|
188
|
+
if (event.type === "turn/start") {
|
|
189
|
+
current = {
|
|
190
|
+
turn: event.data.turn,
|
|
191
|
+
startSeq: event.seq,
|
|
192
|
+
assistants: []
|
|
193
|
+
};
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (current === void 0) continue;
|
|
197
|
+
if (event.type === "user/message" && current.user === void 0 && event.data.source.kind === "user") {
|
|
198
|
+
current.user = event;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (event.type === "assistant/message" && event.data.turn === current.turn) {
|
|
202
|
+
current.assistants.push(event);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (event.type === "turn/end" && event.data.turn === current.turn) {
|
|
206
|
+
result.push({
|
|
207
|
+
...current,
|
|
208
|
+
endSeq: event.seq
|
|
209
|
+
});
|
|
210
|
+
current = void 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
function editableMessages(turns) {
|
|
216
|
+
const result = [];
|
|
217
|
+
for (const turn of turns) {
|
|
218
|
+
if (turn.user !== void 0) {
|
|
219
|
+
const content = structuredClone(turn.user.data.content);
|
|
220
|
+
const attachments = content.flatMap((block, blockIndex) => isAttachmentBlock(block) ? [{
|
|
221
|
+
blockIndex,
|
|
222
|
+
content: block
|
|
223
|
+
}] : []);
|
|
224
|
+
const textBlocks = content.flatMap((block, blockIndex) => block.type === "text" ? [{
|
|
225
|
+
blockIndex,
|
|
226
|
+
text: block.text
|
|
227
|
+
}] : []);
|
|
228
|
+
if (textBlocks.length === 0 && attachments.length > 0) textBlocks.push({
|
|
229
|
+
blockIndex: content.length,
|
|
230
|
+
text: ""
|
|
231
|
+
});
|
|
232
|
+
for (const { blockIndex, text } of textBlocks) result.push({
|
|
233
|
+
key: `${String(turn.user.seq)}:${String(blockIndex)}`,
|
|
234
|
+
turn: turn.turn,
|
|
235
|
+
eventSeq: turn.user.seq,
|
|
236
|
+
blockIndex,
|
|
237
|
+
kind: "user",
|
|
238
|
+
text,
|
|
239
|
+
time: turn.user.time,
|
|
240
|
+
content,
|
|
241
|
+
attachments
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
for (const event of turn.assistants) for (const [blockIndex, block] of event.data.message.content.entries()) {
|
|
245
|
+
if (!isTextualBlock(block)) continue;
|
|
246
|
+
result.push({
|
|
247
|
+
key: `${String(event.seq)}:${String(blockIndex)}`,
|
|
248
|
+
turn: turn.turn,
|
|
249
|
+
eventSeq: event.seq,
|
|
250
|
+
blockIndex,
|
|
251
|
+
kind: block.type === "reasoning" ? "assistant.reasoning" : "assistant.response",
|
|
252
|
+
text: block.text,
|
|
253
|
+
time: event.time
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
function retryableTurns(turns) {
|
|
260
|
+
return turns.flatMap((turn) => turn.user === void 0 ? [] : [{
|
|
261
|
+
turn: turn.turn,
|
|
262
|
+
userEventSeq: turn.user.seq,
|
|
263
|
+
preview: userText(turn.user.data),
|
|
264
|
+
time: turn.user.time
|
|
265
|
+
}]);
|
|
266
|
+
}
|
|
267
|
+
function downstreamUsers(turns, start) {
|
|
268
|
+
return turns.slice(start).flatMap((turn) => turn.user === void 0 ? [] : [cloneUser(turn.user.data)]);
|
|
269
|
+
}
|
|
270
|
+
function assistantReplacement(event, blockIndex, text) {
|
|
271
|
+
const replaced = replaceTextBlock(event.data.message.content, blockIndex, text).filter((block) => block.type === "text" || block.type === "reasoning");
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
id: crypto.randomUUID(),
|
|
274
|
+
role: "assistant",
|
|
275
|
+
content: Object.freeze(replaced),
|
|
276
|
+
source: Object.freeze({
|
|
277
|
+
kind: "model",
|
|
278
|
+
provider: event.data.message.source.provider,
|
|
279
|
+
model: event.data.message.source.model
|
|
280
|
+
})
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function editPlan(operation, turns, editedUserContent) {
|
|
284
|
+
const turnIndex = turns.findIndex((turn) => operation.eventSeq > turn.startSeq && operation.eventSeq < turn.endSeq);
|
|
285
|
+
const turn = turns[turnIndex];
|
|
286
|
+
if (turn === void 0) throw new Error("所选消息不属于已落定回合。");
|
|
287
|
+
const event = turn.user?.seq === operation.eventSeq ? turn.user : turn.assistants.find((candidate) => candidate.seq === operation.eventSeq);
|
|
288
|
+
if (event === void 0) throw new Error("所选消息不存在或不可编辑。");
|
|
289
|
+
if (event.type === "user/message") {
|
|
290
|
+
const before = event.data.content[operation.blockIndex];
|
|
291
|
+
const edited = cloneUser(event.data, editedUserContent ?? replaceUserText(event.data.content, operation.blockIndex, operation.text));
|
|
292
|
+
const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
|
|
293
|
+
return {
|
|
294
|
+
boundary: turn.startSeq - 1,
|
|
295
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
296
|
+
operation: "edit",
|
|
297
|
+
cascade: operation.cascade,
|
|
298
|
+
regenerate: true,
|
|
299
|
+
targetTurn: turn.turn,
|
|
300
|
+
targetEventSeq: event.seq,
|
|
301
|
+
targetBlockIndex: operation.blockIndex,
|
|
302
|
+
blockKind: "user",
|
|
303
|
+
before: before?.type === "text" ? before.text : "",
|
|
304
|
+
after: operation.text
|
|
305
|
+
}),
|
|
306
|
+
queuedUsers: [edited, ...later]
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const before = event.data.message.content[operation.blockIndex];
|
|
310
|
+
if (!isTextualBlock(before)) throw new Error("所选助手消息块不是文本或思考。");
|
|
311
|
+
const blockKind = before.type === "reasoning" ? "assistant.reasoning" : "assistant.response";
|
|
312
|
+
if (turn.user === void 0) throw new Error("所选助手消息没有可重建的用户输入。");
|
|
313
|
+
return {
|
|
314
|
+
boundary: turn.startSeq - 1,
|
|
315
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
316
|
+
operation: "edit",
|
|
317
|
+
cascade: operation.cascade,
|
|
318
|
+
targetTurn: turn.turn,
|
|
319
|
+
targetEventSeq: event.seq,
|
|
320
|
+
targetBlockIndex: operation.blockIndex,
|
|
321
|
+
blockKind,
|
|
322
|
+
before: before.text,
|
|
323
|
+
after: operation.text
|
|
324
|
+
}),
|
|
325
|
+
manualTurn: {
|
|
326
|
+
turn: turn.turn,
|
|
327
|
+
user: cloneUser(turn.user.data),
|
|
328
|
+
assistant: assistantReplacement(event, operation.blockIndex, operation.text)
|
|
329
|
+
},
|
|
330
|
+
queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : []
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function retryPlan(sessionId, turnNumber, cascade, turns) {
|
|
334
|
+
const turnIndex = turns.findIndex((turn) => turn.turn === turnNumber);
|
|
335
|
+
const turn = turns[turnIndex];
|
|
336
|
+
if (turn?.user === void 0) throw new Error("所选回合没有可重放的用户输入。");
|
|
337
|
+
return {
|
|
338
|
+
boundary: turn.startSeq - 1,
|
|
339
|
+
version: pairVersionEffect(sessionId, {
|
|
340
|
+
operation: "retry",
|
|
341
|
+
cascade,
|
|
342
|
+
targetTurn: turn.turn,
|
|
343
|
+
targetEventSeq: turn.user.seq
|
|
344
|
+
}),
|
|
345
|
+
queuedUsers: cascade === "preserve" ? downstreamUsers(turns, turnIndex) : [cloneUser(turn.user.data)]
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function rerollPlan(sessionId, turns) {
|
|
349
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
350
|
+
const turn = turns[index];
|
|
351
|
+
if (turn?.user === void 0) continue;
|
|
352
|
+
const target = turn.assistants.findLast((event) => event.data.message.content.some(isTextualBlock));
|
|
353
|
+
if (target === void 0) continue;
|
|
354
|
+
return {
|
|
355
|
+
boundary: turn.startSeq - 1,
|
|
356
|
+
version: pairVersionEffect(sessionId, {
|
|
357
|
+
operation: "reroll",
|
|
358
|
+
cascade: "truncate",
|
|
359
|
+
targetTurn: turn.turn,
|
|
360
|
+
targetEventSeq: target.seq
|
|
361
|
+
}),
|
|
362
|
+
queuedUsers: [cloneUser(turn.user.data)]
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
throw new Error("当前会话没有可重生成的已落定助手回复。");
|
|
366
|
+
}
|
|
367
|
+
function planOperation(operation, events, editedUserContent) {
|
|
368
|
+
const turns = closedTurns(events);
|
|
369
|
+
switch (operation.action) {
|
|
370
|
+
case "edit": return editPlan(operation, turns, editedUserContent);
|
|
371
|
+
case "reroll": return rerollPlan(operation.sessionId, turns);
|
|
372
|
+
case "retry": return retryPlan(operation.sessionId, operation.turn, operation.cascade, turns);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function agentOptions(events, fallback) {
|
|
376
|
+
const config = events.findLast((event) => event.type === "request/header")?.data.header.config;
|
|
377
|
+
const selected = lastModelSelection(events);
|
|
378
|
+
const provider = selected?.provider ?? fallback?.provider;
|
|
379
|
+
const model = selected?.model ?? fallback?.model;
|
|
380
|
+
if (provider === void 0 || provider.length === 0 || model === void 0 || model.length === 0) throw new Error("无法从会话历史解析模型路由。");
|
|
381
|
+
const maxTokens = config?.maxTokens ?? fallback?.maxTokens;
|
|
382
|
+
const reasoningEffort = selected === void 0 ? fallback?.reasoningEffort : selected.reasoningEffort;
|
|
383
|
+
return {
|
|
384
|
+
provider,
|
|
385
|
+
model,
|
|
386
|
+
...maxTokens === void 0 ? {} : { maxTokens },
|
|
387
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort }
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async function withSourceAgent(ctx, sessionId, operation) {
|
|
391
|
+
let handle;
|
|
392
|
+
let agent = ctx.agents.get(sessionId);
|
|
393
|
+
if (agent === void 0) {
|
|
394
|
+
const snapshot = await ctx.sessionQuery.readSession(sessionId);
|
|
395
|
+
handle = await ctx.agents.resume({
|
|
396
|
+
resumeSessionId: sessionId,
|
|
397
|
+
agentOptions: agentOptions(snapshot.events),
|
|
398
|
+
...await presetComposition(ctx, snapshot.session, snapshot.events)
|
|
399
|
+
});
|
|
400
|
+
agent = handle.agent;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
return await agent.runMaintenance(async () => operation(agent));
|
|
404
|
+
} finally {
|
|
405
|
+
await handle?.dispose();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function inheritedSeed(source, boundary) {
|
|
409
|
+
if (boundary === -1) return [];
|
|
410
|
+
const boundaryEvent = source[boundary];
|
|
411
|
+
if (boundary < 0 || boundaryEvent === void 0 || boundaryEvent.seq !== boundary) throw new Error("分支边界不是连续会话事件。");
|
|
412
|
+
return source.slice(0, boundary + 1);
|
|
413
|
+
}
|
|
414
|
+
/** Build seed envelopes locally; Session construction performs canonical validation and freezing. */
|
|
415
|
+
function appendLogSeedEvent(events, type, data) {
|
|
416
|
+
events.push({
|
|
417
|
+
type,
|
|
418
|
+
seq: events.length,
|
|
419
|
+
time: Date.now(),
|
|
420
|
+
data,
|
|
421
|
+
...type === "message-edit/version" ? { ignorable: true } : {}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
function appendSurfaceSeedEvent(events, type, data, intent) {
|
|
425
|
+
events.push({
|
|
426
|
+
type,
|
|
427
|
+
seq: events.length,
|
|
428
|
+
time: Date.now(),
|
|
429
|
+
data,
|
|
430
|
+
surfaceOp: intent.surfaceOp,
|
|
431
|
+
...intent.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: intent.sourceEventSeqs }
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
function appendManualTurn(events, manual, reserveSystemHead) {
|
|
435
|
+
const { turn, user, assistant } = manual;
|
|
436
|
+
appendLogSeedEvent(events, "turn/start", { turn });
|
|
437
|
+
appendLogSeedEvent(events, "step/start", {
|
|
438
|
+
turn,
|
|
439
|
+
step: 1
|
|
440
|
+
});
|
|
441
|
+
if (reserveSystemHead) appendSurfaceSeedEvent(events, "system/message", {
|
|
442
|
+
turn,
|
|
443
|
+
step: 1,
|
|
444
|
+
message: createSystemMessage("")
|
|
445
|
+
}, { surfaceOp: "append" });
|
|
446
|
+
appendSurfaceSeedEvent(events, "user/message", user, { surfaceOp: "append" });
|
|
447
|
+
appendSurfaceSeedEvent(events, "assistant/message", {
|
|
448
|
+
turn,
|
|
449
|
+
step: 1,
|
|
450
|
+
message: assistant,
|
|
451
|
+
stream: []
|
|
452
|
+
}, { surfaceOp: "append" });
|
|
453
|
+
appendLogSeedEvent(events, "step/end", {
|
|
454
|
+
turn,
|
|
455
|
+
step: 1
|
|
456
|
+
});
|
|
457
|
+
appendLogSeedEvent(events, "turn/end", {
|
|
458
|
+
turn,
|
|
459
|
+
reason: { kind: "completed" }
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function versionSeed(source, plan, projections) {
|
|
463
|
+
const events = inheritedSeed(source, plan.boundary);
|
|
464
|
+
const inheritedLength = events.length;
|
|
465
|
+
const saved = savedUserMessages(source);
|
|
466
|
+
const inheritedSaved = savedUserMessages(events);
|
|
467
|
+
const nodes = saved.size === 0 ? [] : foldSurface(events, projections).nodes;
|
|
468
|
+
appendLogSeedEvent(events, "session/end-seed", { inherited: true });
|
|
469
|
+
for (const [originalSeq, latest] of saved) {
|
|
470
|
+
const original = events[originalSeq];
|
|
471
|
+
if (originalSeq >= inheritedLength || original?.type !== "user/message") continue;
|
|
472
|
+
const current = inheritedSaved.get(originalSeq) ?? original;
|
|
473
|
+
if (latest.seq === current.seq || !nodes.includes(current.seq)) continue;
|
|
474
|
+
appendSurfaceSeedEvent(events, "user/message", latest.data, {
|
|
475
|
+
surfaceOp: {
|
|
476
|
+
op: "replace",
|
|
477
|
+
startSeq: current.seq,
|
|
478
|
+
endSeq: current.seq
|
|
479
|
+
},
|
|
480
|
+
sourceEventSeqs: [current.seq]
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const pending = {
|
|
484
|
+
"next-turn": 0,
|
|
485
|
+
"next-step": 0
|
|
486
|
+
};
|
|
487
|
+
for (const event of events) {
|
|
488
|
+
if (event.type !== "agent/inbox/spliced") continue;
|
|
489
|
+
pending[event.data.target] += event.data.inserted.length - (event.data.removedCount ?? 0);
|
|
490
|
+
}
|
|
491
|
+
for (const target of ["next-step", "next-turn"]) {
|
|
492
|
+
if (pending[target] === 0) continue;
|
|
493
|
+
appendLogSeedEvent(events, "agent/inbox/spliced", {
|
|
494
|
+
target,
|
|
495
|
+
start: 0,
|
|
496
|
+
removedCount: pending[target],
|
|
497
|
+
inserted: [],
|
|
498
|
+
outcome: "canceled"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
appendLogSeedEvent(events, "message-edit/version", plan.version);
|
|
502
|
+
if (plan.manualTurn !== void 0) appendManualTurn(events, plan.manualTurn, foldSurface(events, projections).nodes.length === 0);
|
|
503
|
+
return {
|
|
504
|
+
events,
|
|
505
|
+
inheritedLength
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function sessionPreset(header, events) {
|
|
509
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
510
|
+
const event = events[index];
|
|
511
|
+
if (event?.type === "agent-preset/selected") return event.data.agentPreset;
|
|
512
|
+
}
|
|
513
|
+
return header.agentPreset;
|
|
514
|
+
}
|
|
515
|
+
async function presetComposition(ctx, header, events = []) {
|
|
516
|
+
const presets = ctx.get("agentPresets");
|
|
517
|
+
const presetId = sessionPreset(header, events);
|
|
518
|
+
if (presets === void 0) {
|
|
519
|
+
if (presetId !== void 0) throw new Error("会话所用的 Agent preset 服务不可用。");
|
|
520
|
+
return {};
|
|
521
|
+
}
|
|
522
|
+
const resolved = (await presets.resolve(presetId)).id;
|
|
523
|
+
return {
|
|
524
|
+
agentPreset: resolved,
|
|
525
|
+
setup: async (agentCtx) => {
|
|
526
|
+
await presets.mount(agentCtx, resolved);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async function createVersionAgent(ctx, source, sourceEvents, childId, plan, options, settings) {
|
|
531
|
+
const seed = versionSeed(sourceEvents, plan, ctx.sessions.messageProjections);
|
|
532
|
+
const { agentPreset, setup } = await presetComposition(ctx, source.header, sourceEvents);
|
|
533
|
+
const child = await ctx.agents.create({
|
|
534
|
+
sessionId: childId,
|
|
535
|
+
seed: seed.events,
|
|
536
|
+
inheritedEventCount: seed.inheritedLength,
|
|
537
|
+
meta: {
|
|
538
|
+
...source.header.cwd === void 0 ? {} : { cwd: source.header.cwd },
|
|
539
|
+
parentSession: source.id,
|
|
540
|
+
isSeeded: true,
|
|
541
|
+
...agentPreset === void 0 ? {} : { agentPreset }
|
|
542
|
+
},
|
|
543
|
+
agentOptions: options,
|
|
544
|
+
...setup === void 0 ? {} : { setup }
|
|
545
|
+
});
|
|
546
|
+
try {
|
|
547
|
+
await applySettings(ctx, child.agent, settings);
|
|
548
|
+
await ctx.sessions.flush(child.agent.session);
|
|
549
|
+
return child;
|
|
550
|
+
} catch (error) {
|
|
551
|
+
await child.dispose();
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function sourceWorkspace(ctx, sessionId) {
|
|
556
|
+
return ctx.workspaceRegistry.list().find((workspace) => workspace.sessionIds.includes(sessionId));
|
|
557
|
+
}
|
|
558
|
+
function sessionController(ctx) {
|
|
559
|
+
const controller = ctx.get("sessionController");
|
|
560
|
+
if (controller === void 0) throw new Error("当前 DSH 未提供公开会话控制接口。");
|
|
561
|
+
return controller;
|
|
562
|
+
}
|
|
563
|
+
function permissions(ctx) {
|
|
564
|
+
return ctx.get("permissionPresets");
|
|
565
|
+
}
|
|
566
|
+
function lastModelSelection(events) {
|
|
567
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
568
|
+
const event = events[index];
|
|
569
|
+
if (event?.type === "model/selection") return event.data;
|
|
570
|
+
if (event?.type === "request/header") {
|
|
571
|
+
const config = event.data.header.config;
|
|
572
|
+
if (config.provider === void 0 || config.model === void 0) continue;
|
|
573
|
+
return {
|
|
574
|
+
provider: config.provider,
|
|
575
|
+
model: config.model,
|
|
576
|
+
...config.reasoningEffort === void 0 || event.data.header.adapterDefaults?.reasoningEffort === true ? {} : { reasoningEffort: config.reasoningEffort }
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function lastPermissionPreset(events) {
|
|
582
|
+
for (const entry of [...events].reverse()) {
|
|
583
|
+
const event = entry;
|
|
584
|
+
if (event.type === "permission/preset" && typeof event.data.preset === "string") return event.data.preset;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function composerOptions(ctx, sessionId, signal) {
|
|
588
|
+
const controller = sessionController(ctx);
|
|
589
|
+
const [snapshot, catalog, projected] = await Promise.all([
|
|
590
|
+
ctx.sessionQuery.readSession(sessionId),
|
|
591
|
+
controller.modelCatalog(),
|
|
592
|
+
controller.projections({ sessionId }, signal)
|
|
593
|
+
]);
|
|
594
|
+
const permissionCatalog = permissions(ctx)?.catalog();
|
|
595
|
+
const selected = projected?.values.modelSelection?.next ?? lastModelSelection(snapshot.events) ?? catalog.default;
|
|
596
|
+
const permissionValue = projected?.values["permissions"];
|
|
597
|
+
const currentPreset = typeof permissionValue === "object" && permissionValue !== null && !Array.isArray(permissionValue) && typeof permissionValue["currentValue"] === "string" ? permissionValue["currentValue"] : lastPermissionPreset(snapshot.events) ?? permissionCatalog?.defaultPreset;
|
|
598
|
+
return {
|
|
599
|
+
current: {
|
|
600
|
+
...selected,
|
|
601
|
+
...currentPreset === void 0 ? {} : { permissionPreset: currentPreset }
|
|
602
|
+
},
|
|
603
|
+
models: catalog.groups.flatMap((group) => group.models.map((model) => ({
|
|
604
|
+
provider: group.id,
|
|
605
|
+
providerLabel: group.name,
|
|
606
|
+
model: model.id,
|
|
607
|
+
label: model.name,
|
|
608
|
+
reasoningEfforts: model.reasoning?.efforts.map((effort) => effort.id) ?? [],
|
|
609
|
+
...model.reasoning === void 0 ? {} : { reasoningEffortLabels: Object.fromEntries(model.reasoning.efforts.map((effort) => [effort.id, effort.name])) }
|
|
610
|
+
}))),
|
|
611
|
+
permissions: permissionCatalog?.options.map((option) => ({
|
|
612
|
+
id: option.value,
|
|
613
|
+
label: option.name,
|
|
614
|
+
...option.description === void 0 ? {} : { description: option.description }
|
|
615
|
+
})) ?? []
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** 校验配置草稿时只读目录,附件准入或正文校验失败不会修改会话设置。 */
|
|
619
|
+
async function validateSettings(ctx, settings) {
|
|
620
|
+
if (settings === void 0) return;
|
|
621
|
+
if (settings.provider !== void 0 && settings.model !== void 0) {
|
|
622
|
+
const model = (await sessionController(ctx).modelCatalog()).groups.find((group) => group.id === settings.provider)?.models.find((model) => model.id === settings.model);
|
|
623
|
+
if (model === void 0) throw new TypeError("所选模型当前不可用。");
|
|
624
|
+
if (settings.reasoningEffort !== void 0 && !model.reasoning?.efforts.some((effort) => effort.id === settings.reasoningEffort)) throw new TypeError("所选模型不支持该推理强度。");
|
|
625
|
+
}
|
|
626
|
+
if (settings.permissionPreset !== void 0) {
|
|
627
|
+
const permission = permissions(ctx);
|
|
628
|
+
if (permission === void 0) throw new Error("当前 DSH 未提供权限预设。");
|
|
629
|
+
if (!permission.catalog().options.some((option) => option.value === settings.permissionPreset)) throw new TypeError("所选权限预设当前不可用。");
|
|
630
|
+
permission.resolve(settings.permissionPreset);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** 只在确认操作后调用与正常输入框一致的公开切换路径。 */
|
|
634
|
+
async function applySettings(ctx, agent, settings) {
|
|
635
|
+
if (settings === void 0) return;
|
|
636
|
+
if (settings.provider !== void 0 && settings.model !== void 0) await sessionController(ctx).selectModel({
|
|
637
|
+
sessionId: agent.id,
|
|
638
|
+
provider: settings.provider,
|
|
639
|
+
model: settings.model,
|
|
640
|
+
...settings.reasoningEffort === void 0 ? {} : { reasoningEffort: settings.reasoningEffort }
|
|
641
|
+
});
|
|
642
|
+
if (settings.permissionPreset !== void 0) permissions(ctx)?.set(agent.session, settings.permissionPreset);
|
|
643
|
+
}
|
|
644
|
+
/** 模型选择省略推理强度表示恢复模型默认值;权限省略则保留原值。 */
|
|
645
|
+
async function changedSettings(ctx, sessionId, settings) {
|
|
646
|
+
if (settings === void 0 || Object.keys(settings).length === 0) return void 0;
|
|
647
|
+
const current = (await composerOptions(ctx, sessionId, new AbortController().signal)).current;
|
|
648
|
+
const changed = {};
|
|
649
|
+
if (settings.provider !== void 0 && settings.model !== void 0 && (settings.provider !== current.provider || settings.model !== current.model || settings.reasoningEffort !== current.reasoningEffort)) {
|
|
650
|
+
changed.provider = settings.provider;
|
|
651
|
+
changed.model = settings.model;
|
|
652
|
+
if (settings.reasoningEffort !== void 0) changed.reasoningEffort = settings.reasoningEffort;
|
|
653
|
+
}
|
|
654
|
+
if (settings.permissionPreset !== void 0 && settings.permissionPreset !== current.permissionPreset) changed.permissionPreset = settings.permissionPreset;
|
|
655
|
+
return Object.keys(changed).length === 0 ? void 0 : changed;
|
|
656
|
+
}
|
|
657
|
+
async function liveAgent(ctx, sessionId) {
|
|
658
|
+
const existing = ctx.agents.get(sessionId);
|
|
659
|
+
if (existing !== void 0) return existing;
|
|
660
|
+
const result = await sessionController(ctx).resolveAgent(sessionId);
|
|
661
|
+
if ("error" in result) throw result.error;
|
|
662
|
+
return result.agent;
|
|
663
|
+
}
|
|
664
|
+
async function references(ctx, sessionId, query, signal) {
|
|
665
|
+
const service = ctx.get("fileReferences");
|
|
666
|
+
if (service === void 0) return [];
|
|
667
|
+
return (await service.list(await liveAgent(ctx, sessionId), query, signal)).map((candidate) => ({
|
|
668
|
+
path: candidate.path,
|
|
669
|
+
label: candidate.path,
|
|
670
|
+
kind: candidate.kind === "directory" ? "folder" : "file"
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
async function openAttachment(ctx, sessionId, eventSeq, blockIndex, signal) {
|
|
674
|
+
const snapshot = await ctx.sessionQuery.readSession(sessionId);
|
|
675
|
+
const original = snapshot.events.find((event) => event.seq === eventSeq);
|
|
676
|
+
if (original?.type !== "user/message") throw new TypeError("所选消息不存在。");
|
|
677
|
+
const block = (savedUserMessages(snapshot.events).get(original.seq) ?? original).data.content[blockIndex];
|
|
678
|
+
if (block?.type !== "file") throw new TypeError("所选附件不是当前消息中的文件。");
|
|
679
|
+
const path = ctx.get("attachments")?.fileHostPath(block.attachment);
|
|
680
|
+
if (path === void 0) throw new Error("当前附件存储不支持在系统应用中打开文件。");
|
|
681
|
+
await sessionController(ctx).openWorkspacePath({ path }, signal);
|
|
682
|
+
}
|
|
683
|
+
async function openUpload(ctx, sessionId, receiptId, signal) {
|
|
684
|
+
const agent = await liveAgent(ctx, sessionId);
|
|
685
|
+
const uploads = ctx.get("fileUploads");
|
|
686
|
+
if (uploads === void 0) throw new Error("当前 DSH 未提供文件上传服务。");
|
|
687
|
+
const attachment = uploads.resolve(agent, receiptId);
|
|
688
|
+
if (attachment === void 0) throw new TypeError("文件上传凭据不属于当前会话或已失效。");
|
|
689
|
+
const path = ctx.get("attachments")?.fileHostPath(attachment);
|
|
690
|
+
if (path === void 0) throw new Error("当前附件存储不支持在系统应用中打开文件。");
|
|
691
|
+
await sessionController(ctx).openWorkspacePath({ path }, signal);
|
|
692
|
+
}
|
|
693
|
+
async function openReference(ctx, sessionId, path, signal) {
|
|
694
|
+
const snapshot = await ctx.sessionQuery.readSession(sessionId);
|
|
695
|
+
const fs = ctx.get("fs");
|
|
696
|
+
if (fs === void 0) throw new Error("当前 DSH 未提供文件系统服务。");
|
|
697
|
+
const controller = sessionController(ctx);
|
|
698
|
+
let cwd = snapshot.session.cwd;
|
|
699
|
+
if (!isAbsolute(path)) {
|
|
700
|
+
const directory = (await controller.projections({ sessionId }, signal))?.values["workingDirectory"];
|
|
701
|
+
if (typeof directory === "string") cwd = directory;
|
|
702
|
+
}
|
|
703
|
+
const target = await fs.resolve(path, {
|
|
704
|
+
...cwd === void 0 ? {} : { cwd },
|
|
705
|
+
signal
|
|
706
|
+
});
|
|
707
|
+
await controller.openWorkspacePath({ path: fs.processPath(target) }, signal);
|
|
708
|
+
}
|
|
709
|
+
async function recoverOperation(inverses) {
|
|
710
|
+
const failures = [];
|
|
711
|
+
for (const inverse of inverses.reverse()) try {
|
|
712
|
+
await inverse();
|
|
713
|
+
} catch (error) {
|
|
714
|
+
failures.push(error);
|
|
715
|
+
}
|
|
716
|
+
if (failures.length > 0) throw new AggregateError(failures, "版本操作恢复失败。");
|
|
717
|
+
}
|
|
718
|
+
async function runOperation(ctx, operation) {
|
|
719
|
+
const sourceId = sessionIdOf(operation.sessionId);
|
|
720
|
+
if (operation.action === "save" || operation.action === "edit" && operation.regenerate === false) return saveUserMessage(ctx, sourceId, operation);
|
|
721
|
+
if (operation.action === "edit" && operation.settings === void 0 && operation.attachments === void 0 && ctx.agents.get(sourceId) === void 0) {
|
|
722
|
+
const { events } = await ctx.sessionQuery.readSession(sourceId);
|
|
723
|
+
const plan = planOperation(operation, visibleUserEvents(events));
|
|
724
|
+
if (plan.manualTurn !== void 0 && plan.version.effect.before === plan.version.effect.after) return {
|
|
725
|
+
sessionId: sourceId,
|
|
726
|
+
queuedTurns: 0,
|
|
727
|
+
unchanged: true
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return withSourceAgent(ctx, sourceId, async (source) => {
|
|
731
|
+
const events = (await ctx.sessionQuery.readSession(sourceId)).events;
|
|
732
|
+
const visible = visibleUserEvents(events);
|
|
733
|
+
const settings = operation.action === "edit" ? operation.settings : void 0;
|
|
734
|
+
if (operation.action === "edit" && settings !== void 0 && !closedTurns(visible).some((turn) => turn.user?.seq === operation.eventSeq)) throw new TypeError("只有用户消息可以设置模型和权限。");
|
|
735
|
+
await validateSettings(ctx, settings);
|
|
736
|
+
const perform = async (editedUserContent) => {
|
|
737
|
+
const childId = sessionIdOf(`session-${crypto.randomUUID()}`);
|
|
738
|
+
const inverses = [];
|
|
739
|
+
try {
|
|
740
|
+
const plan = planOperation(operation, visible, editedUserContent);
|
|
741
|
+
if (plan.manualTurn !== void 0 && plan.version.effect.before === plan.version.effect.after) return {
|
|
742
|
+
sessionId: sourceId,
|
|
743
|
+
queuedTurns: 0,
|
|
744
|
+
unchanged: true
|
|
745
|
+
};
|
|
746
|
+
const options = agentOptions(events, source.options);
|
|
747
|
+
const child = await createVersionAgent(ctx, source.session, events, childId, plan, options, settings);
|
|
748
|
+
inverses.push(() => child.dispose());
|
|
749
|
+
const workspace = sourceWorkspace(ctx, sourceId);
|
|
750
|
+
if (workspace !== void 0) {
|
|
751
|
+
await workspace.attachSession(childId);
|
|
752
|
+
inverses.push(() => workspace.detachSession(childId));
|
|
753
|
+
}
|
|
754
|
+
for (const message of plan.queuedUsers) child.agent.followup(message);
|
|
755
|
+
inverses.length = 0;
|
|
756
|
+
return {
|
|
757
|
+
sessionId: childId,
|
|
758
|
+
queuedTurns: plan.queuedUsers.length
|
|
759
|
+
};
|
|
760
|
+
} catch (error) {
|
|
761
|
+
try {
|
|
762
|
+
await recoverOperation(inverses);
|
|
763
|
+
} catch (recoveryError) {
|
|
764
|
+
throw new AggregateError([error, recoveryError], "版本操作及其恢复均失败。");
|
|
765
|
+
}
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
if (operation.action === "edit") {
|
|
770
|
+
const user = closedTurns(visible).find((turn) => turn.user?.seq === operation.eventSeq)?.user;
|
|
771
|
+
if (user !== void 0) return withEditedUserContent(ctx, source, user.data, operation, perform);
|
|
772
|
+
if (operation.attachments !== void 0) throw new TypeError("只有用户消息可以编辑附件。");
|
|
773
|
+
}
|
|
774
|
+
return perform();
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async function saveUserMessage(ctx, sourceId, operation) {
|
|
778
|
+
if (ctx.agents.get(sourceId) === void 0) {
|
|
779
|
+
const { events } = await ctx.sessionQuery.readSession(sourceId);
|
|
780
|
+
const original = closedTurns(events).find((turn) => turn.user?.seq === operation.eventSeq)?.user;
|
|
781
|
+
if (original !== void 0) {
|
|
782
|
+
const current = savedUserMessages(events).get(original.seq) ?? original;
|
|
783
|
+
let content = replaceUserText(current.data.content, operation.blockIndex, operation.text);
|
|
784
|
+
if (operation.attachments !== void 0) {
|
|
785
|
+
const retained = [];
|
|
786
|
+
for (const input of operation.attachments) {
|
|
787
|
+
if (input.type !== "retained") {
|
|
788
|
+
content = void 0;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
const block = current.data.content[input.blockIndex];
|
|
792
|
+
if (!isAttachmentBlock(block)) throw new TypeError("保留的附件位置不存在或不是附件。");
|
|
793
|
+
retained.push(block);
|
|
794
|
+
}
|
|
795
|
+
if (content !== void 0) content = replaceUserAttachments(content, retained);
|
|
796
|
+
}
|
|
797
|
+
if (content !== void 0 && equalContentValue(content, current.data.content) && foldSurface(events, ctx.sessions.messageProjections).nodes.includes(current.seq)) {
|
|
798
|
+
await validateSettings(ctx, operation.settings);
|
|
799
|
+
if (await changedSettings(ctx, sourceId, operation.settings) === void 0) return {
|
|
800
|
+
sessionId: sourceId,
|
|
801
|
+
queuedTurns: 0,
|
|
802
|
+
saved: true,
|
|
803
|
+
unchanged: true
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return withSourceAgent(ctx, sourceId, async (source) => {
|
|
809
|
+
const events = (await ctx.sessionQuery.readSession(sourceId)).events;
|
|
810
|
+
const original = closedTurns(events).find((candidate) => candidate.user?.seq === operation.eventSeq)?.user;
|
|
811
|
+
if (original === void 0) throw new Error("所选用户消息不存在或回合尚未结束。");
|
|
812
|
+
const current = savedUserMessages(events).get(original.seq) ?? original;
|
|
813
|
+
if (!source.session.surface.nodes.includes(current.seq)) throw new Error("该消息已不在当前模型上下文中,无法在当前会话保存;可以使用“保存并发送”创建新版本。");
|
|
814
|
+
return withEditedUserContent(ctx, source, current.data, operation, async (content) => {
|
|
815
|
+
await validateSettings(ctx, operation.settings);
|
|
816
|
+
const settings = await changedSettings(ctx, sourceId, operation.settings);
|
|
817
|
+
const contentChanged = !equalContentValue(content, current.data.content);
|
|
818
|
+
if (!contentChanged && settings === void 0) return {
|
|
819
|
+
sessionId: sourceId,
|
|
820
|
+
queuedTurns: 0,
|
|
821
|
+
saved: true,
|
|
822
|
+
unchanged: true
|
|
823
|
+
};
|
|
824
|
+
await applySettings(ctx, source, settings);
|
|
825
|
+
const messageSource = {
|
|
826
|
+
...current.data.source,
|
|
827
|
+
messageEdit: { originalEventSeq: original.seq }
|
|
828
|
+
};
|
|
829
|
+
if (contentChanged) source.session.append("user/message", {
|
|
830
|
+
...current.data,
|
|
831
|
+
content,
|
|
832
|
+
source: messageSource
|
|
833
|
+
}, {
|
|
834
|
+
surfaceOp: {
|
|
835
|
+
op: "replace",
|
|
836
|
+
startSeq: current.seq,
|
|
837
|
+
endSeq: current.seq
|
|
838
|
+
},
|
|
839
|
+
sourceEventSeqs: [current.seq]
|
|
840
|
+
});
|
|
841
|
+
await ctx.sessions.flush(source.session);
|
|
842
|
+
return {
|
|
843
|
+
sessionId: sourceId,
|
|
844
|
+
queuedTurns: 0,
|
|
845
|
+
saved: true
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
function ownVersionEvent(header, events, inherited) {
|
|
851
|
+
const ownEvents = events.filter((event) => event.type === "message-edit/version" && event.seq >= inherited);
|
|
852
|
+
if (ownEvents.length === 0) return void 0;
|
|
853
|
+
if (ownEvents.length > 1) throw new Error(`会话 ${header.id} 包含多个自身版本效果。`);
|
|
854
|
+
const event = ownEvents[0];
|
|
855
|
+
if (event === void 0) return void 0;
|
|
856
|
+
const parent = header.parentSession;
|
|
857
|
+
if ("schemaVersion" in event.data) {
|
|
858
|
+
const version = event.data;
|
|
859
|
+
if (version.schemaVersion !== 2) throw new Error(`会话 ${header.id} 使用不支持的版本效果结构。`);
|
|
860
|
+
if (version.inverse.kind !== "restore-version" || parent === void 0 || version.inverse.sessionId !== parent) throw new Error(`会话 ${header.id} 的版本效果与逆不匹配。`);
|
|
861
|
+
return {
|
|
862
|
+
effect: version.effect,
|
|
863
|
+
inverseSessionId: version.inverse.sessionId,
|
|
864
|
+
time: event.time
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const legacy = event.data;
|
|
868
|
+
if (parent === void 0 || legacy.sourceSessionId !== parent) throw new Error(`会话 ${header.id} 的旧版恢复目标与父版本不匹配。`);
|
|
869
|
+
return {
|
|
870
|
+
effect: {
|
|
871
|
+
id: `legacy:${header.id}:${String(event.seq)}`,
|
|
872
|
+
operation: legacy.operation,
|
|
873
|
+
cascade: legacy.cascade,
|
|
874
|
+
targetTurn: legacy.targetTurn,
|
|
875
|
+
targetEventSeq: legacy.targetEventSeq,
|
|
876
|
+
...legacy.targetBlockIndex === void 0 ? {} : { targetBlockIndex: legacy.targetBlockIndex },
|
|
877
|
+
...legacy.blockKind === void 0 ? {} : { blockKind: legacy.blockKind },
|
|
878
|
+
...legacy.before === void 0 ? {} : { before: legacy.before },
|
|
879
|
+
...legacy.after === void 0 ? {} : { after: legacy.after }
|
|
880
|
+
},
|
|
881
|
+
inverseSessionId: legacy.sourceSessionId,
|
|
882
|
+
time: event.time
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function flattenLineage(root, descendants) {
|
|
886
|
+
const result = [{
|
|
887
|
+
record: root,
|
|
888
|
+
depth: 0
|
|
889
|
+
}];
|
|
890
|
+
const visit = (nodes, depth) => {
|
|
891
|
+
const ordered = [...nodes].sort((left, right) => left.session.header.createdAt - right.session.header.createdAt || String(left.session.header.id).localeCompare(String(right.session.header.id)));
|
|
892
|
+
for (const node of ordered) {
|
|
893
|
+
result.push({
|
|
894
|
+
record: node.session,
|
|
895
|
+
depth
|
|
896
|
+
});
|
|
897
|
+
visit(node.descendants, depth + 1);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
visit(descendants, 1);
|
|
901
|
+
return result;
|
|
902
|
+
}
|
|
903
|
+
/** Bounded parallel inspection of persisted branches; matches the corpus worker shape. */
|
|
904
|
+
const TIMELINE_READ_CONCURRENCY = 4;
|
|
905
|
+
async function mapConcurrent(items, worker) {
|
|
906
|
+
const results = new Array(items.length);
|
|
907
|
+
let cursor = 0;
|
|
908
|
+
const run = async () => {
|
|
909
|
+
for (;;) {
|
|
910
|
+
const index = cursor;
|
|
911
|
+
cursor += 1;
|
|
912
|
+
if (index >= items.length) return;
|
|
913
|
+
results[index] = await worker(items[index]);
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
const workers = Math.min(TIMELINE_READ_CONCURRENCY, items.length);
|
|
917
|
+
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
918
|
+
return results;
|
|
919
|
+
}
|
|
920
|
+
async function timeline(ctx, sessionId) {
|
|
921
|
+
const currentLog = await ctx.sessionQuery.readSession(sessionId);
|
|
922
|
+
const targetTrace = await ctx.sessionQuery.traceSession(sessionId);
|
|
923
|
+
const rootId = targetTrace.complete ? targetTrace.root.header.id : targetTrace.ancestors.at(-1)?.header.id ?? sessionId;
|
|
924
|
+
const rootTrace = rootId === sessionId ? targetTrace : await ctx.sessionQuery.traceSession(rootId);
|
|
925
|
+
const lineage = flattenLineage(rootTrace.target, rootTrace.descendants);
|
|
926
|
+
const inspections = await mapConcurrent(lineage, async ({ record }) => {
|
|
927
|
+
if (record.header.id === sessionId) return { version: ownVersionEvent(record.header, currentLog.events, currentLog.inheritedEventCount) };
|
|
928
|
+
try {
|
|
929
|
+
const log = await ctx.sessionQuery.readSession(record.header.id);
|
|
930
|
+
return { version: ownVersionEvent(record.header, log.events, log.inheritedEventCount) };
|
|
931
|
+
} catch (error) {
|
|
932
|
+
return { unavailableReason: error instanceof Error ? error.message : String(error) };
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
const recordsById = new Map(lineage.map(({ record }) => [record.header.id, record]));
|
|
936
|
+
const currentPath = /* @__PURE__ */ new Set();
|
|
937
|
+
let pathId = sessionId;
|
|
938
|
+
while (pathId !== void 0 && !currentPath.has(pathId)) {
|
|
939
|
+
currentPath.add(pathId);
|
|
940
|
+
pathId = recordsById.get(pathId)?.header.parentSession;
|
|
941
|
+
}
|
|
942
|
+
const versions = lineage.map(({ record, depth }, index) => {
|
|
943
|
+
const inspection = inspections[index];
|
|
944
|
+
const version = inspection?.version;
|
|
945
|
+
return {
|
|
946
|
+
sessionId: record.header.id,
|
|
947
|
+
...record.header.parentSession === void 0 ? {} : { parentSessionId: record.header.parentSession },
|
|
948
|
+
...version === void 0 ? {} : {
|
|
949
|
+
effectId: version.effect.id,
|
|
950
|
+
inverseSessionId: version.inverseSessionId
|
|
951
|
+
},
|
|
952
|
+
createdAt: version?.time ?? record.header.createdAt,
|
|
953
|
+
depth,
|
|
954
|
+
current: record.header.id === sessionId,
|
|
955
|
+
onCurrentEffectPath: currentPath.has(record.header.id),
|
|
956
|
+
...inspection?.unavailableReason === void 0 ? {} : { unavailableReason: inspection.unavailableReason },
|
|
957
|
+
...version === void 0 ? {} : {
|
|
958
|
+
operation: version.effect.operation,
|
|
959
|
+
cascade: version.effect.cascade,
|
|
960
|
+
targetTurn: version.effect.targetTurn,
|
|
961
|
+
...version.effect.blockKind === void 0 ? {} : { blockKind: version.effect.blockKind },
|
|
962
|
+
...version.effect.before === void 0 ? {} : { before: version.effect.before },
|
|
963
|
+
...version.effect.after === void 0 ? {} : { after: version.effect.after }
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
});
|
|
967
|
+
const effectIds = /* @__PURE__ */ new Set();
|
|
968
|
+
for (const version of versions) {
|
|
969
|
+
if (version.effectId === void 0) continue;
|
|
970
|
+
if (effectIds.has(version.effectId)) throw new Error(`版本效果 ${version.effectId} 重复。`);
|
|
971
|
+
effectIds.add(version.effectId);
|
|
972
|
+
}
|
|
973
|
+
const versionsById = new Map(versions.map((version) => [version.sessionId, version]));
|
|
974
|
+
const undoStack = [];
|
|
975
|
+
let undoCursor = versionsById.get(sessionId);
|
|
976
|
+
while (undoCursor?.inverseSessionId !== void 0) {
|
|
977
|
+
const inverseId = undoCursor.inverseSessionId;
|
|
978
|
+
if (undoStack.includes(inverseId)) throw new Error("版本效果逆链包含循环。");
|
|
979
|
+
if (!versionsById.has(inverseId)) throw new Error(`恢复目标 ${inverseId} 不在可见版本树中。`);
|
|
980
|
+
if (versionsById.get(inverseId)?.unavailableReason !== void 0) break;
|
|
981
|
+
undoStack.push(inverseId);
|
|
982
|
+
undoCursor = versionsById.get(inverseId);
|
|
983
|
+
}
|
|
984
|
+
const redoSessionIds = versions.filter((version) => version.inverseSessionId === sessionId && version.unavailableReason === void 0).map((version) => version.sessionId);
|
|
985
|
+
if (versions.findIndex((version) => version.current) < 0) throw new Error("当前版本不在版本树中。");
|
|
986
|
+
const turns = closedTurns(visibleUserEvents(currentLog.events));
|
|
987
|
+
return {
|
|
988
|
+
sessionId,
|
|
989
|
+
build: MESSAGE_EDIT_BUILD_INFO,
|
|
990
|
+
messages: editableMessages(turns),
|
|
991
|
+
retryableTurns: retryableTurns(turns),
|
|
992
|
+
versions,
|
|
993
|
+
undoStack,
|
|
994
|
+
redoSessionIds
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function objectValue(value) {
|
|
998
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("请求体必须是 JSON 对象。");
|
|
999
|
+
return value;
|
|
1000
|
+
}
|
|
1001
|
+
function sessionIdOf(value) {
|
|
1002
|
+
if (typeof value !== "string" || value.length === 0) throw new TypeError("sessionId 必须是非空字符串。");
|
|
1003
|
+
return value;
|
|
1004
|
+
}
|
|
1005
|
+
function integerOf(value, name) {
|
|
1006
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} 必须是非负安全整数。`);
|
|
1007
|
+
return value;
|
|
1008
|
+
}
|
|
1009
|
+
function cascadeOf(value) {
|
|
1010
|
+
if (value !== "truncate" && value !== "preserve") throw new TypeError("cascade 必须是 truncate 或 preserve。");
|
|
1011
|
+
return value;
|
|
1012
|
+
}
|
|
1013
|
+
function attachmentsOf(value) {
|
|
1014
|
+
if (value === void 0) return void 0;
|
|
1015
|
+
if (!Array.isArray(value)) throw new TypeError("attachments 必须是数组。");
|
|
1016
|
+
return value.map((input) => {
|
|
1017
|
+
const attachment = objectValue(input);
|
|
1018
|
+
switch (attachment["type"]) {
|
|
1019
|
+
case "retained": return {
|
|
1020
|
+
type: "retained",
|
|
1021
|
+
blockIndex: integerOf(attachment["blockIndex"], "附件 blockIndex")
|
|
1022
|
+
};
|
|
1023
|
+
case "file":
|
|
1024
|
+
if (typeof attachment["receiptId"] !== "string" || attachment["receiptId"].length === 0) throw new TypeError("文件附件需要有效的 receiptId。");
|
|
1025
|
+
return {
|
|
1026
|
+
type: "file",
|
|
1027
|
+
receiptId: attachment["receiptId"]
|
|
1028
|
+
};
|
|
1029
|
+
case "image": {
|
|
1030
|
+
const mediaType = attachment["mediaType"];
|
|
1031
|
+
if (mediaType !== "image/png" && mediaType !== "image/jpeg" && mediaType !== "image/webp" && mediaType !== "image/gif") throw new TypeError("图片格式必须是 PNG、JPEG、WebP 或 GIF。");
|
|
1032
|
+
if (typeof attachment["data"] !== "string" || attachment["data"].length === 0) throw new TypeError("图片附件需要上传字节,不能直接提交持久引用。");
|
|
1033
|
+
if (attachment["name"] !== void 0 && typeof attachment["name"] !== "string") throw new TypeError("附件 name 必须是字符串。");
|
|
1034
|
+
return {
|
|
1035
|
+
type: "image",
|
|
1036
|
+
mediaType,
|
|
1037
|
+
data: attachment["data"],
|
|
1038
|
+
...attachment["name"] === void 0 ? {} : { name: attachment["name"] }
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
default: throw new TypeError("附件必须是已有附件位置、图片上传或文件上传凭据。");
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
function nonEmptyString(value, name) {
|
|
1046
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0")) throw new TypeError(`${name} 必须是非空字符串。`);
|
|
1047
|
+
return value;
|
|
1048
|
+
}
|
|
1049
|
+
function settingsOf(value) {
|
|
1050
|
+
if (value === void 0) return void 0;
|
|
1051
|
+
const settings = objectValue(value);
|
|
1052
|
+
const provider = settings["provider"] === void 0 ? void 0 : nonEmptyString(settings["provider"], "provider");
|
|
1053
|
+
const model = settings["model"] === void 0 ? void 0 : nonEmptyString(settings["model"], "model");
|
|
1054
|
+
if (provider === void 0 !== (model === void 0)) throw new TypeError("provider 和 model 必须同时提供。");
|
|
1055
|
+
const effort = settings["reasoningEffort"] === void 0 ? void 0 : nonEmptyString(settings["reasoningEffort"], "reasoningEffort");
|
|
1056
|
+
if (effort !== void 0 && model === void 0) throw new TypeError("推理强度必须与模型一起提供。");
|
|
1057
|
+
return {
|
|
1058
|
+
...provider === void 0 ? {} : { provider },
|
|
1059
|
+
...model === void 0 ? {} : { model },
|
|
1060
|
+
...effort === void 0 ? {} : { reasoningEffort: effort },
|
|
1061
|
+
...settings["permissionPreset"] === void 0 ? {} : { permissionPreset: nonEmptyString(settings["permissionPreset"], "permissionPreset") }
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
function decodeOperation(value) {
|
|
1065
|
+
const record = objectValue(value);
|
|
1066
|
+
const sessionId = sessionIdOf(record["sessionId"]);
|
|
1067
|
+
const attachments = attachmentsOf(record["attachments"]);
|
|
1068
|
+
const settings = settingsOf(record["settings"]);
|
|
1069
|
+
switch (record["action"]) {
|
|
1070
|
+
case "save":
|
|
1071
|
+
if (typeof record["text"] !== "string") throw new TypeError("text 必须是字符串。");
|
|
1072
|
+
return {
|
|
1073
|
+
action: "save",
|
|
1074
|
+
sessionId,
|
|
1075
|
+
eventSeq: integerOf(record["eventSeq"], "eventSeq"),
|
|
1076
|
+
blockIndex: integerOf(record["blockIndex"], "blockIndex"),
|
|
1077
|
+
text: record["text"],
|
|
1078
|
+
...attachments === void 0 ? {} : { attachments },
|
|
1079
|
+
...settings === void 0 ? {} : { settings }
|
|
1080
|
+
};
|
|
1081
|
+
case "edit":
|
|
1082
|
+
if (typeof record["text"] !== "string") throw new TypeError("text 必须是字符串。");
|
|
1083
|
+
if (record["regenerate"] !== void 0 && typeof record["regenerate"] !== "boolean") throw new TypeError("regenerate 必须是布尔值。");
|
|
1084
|
+
return {
|
|
1085
|
+
action: "edit",
|
|
1086
|
+
sessionId,
|
|
1087
|
+
eventSeq: integerOf(record["eventSeq"], "eventSeq"),
|
|
1088
|
+
blockIndex: integerOf(record["blockIndex"], "blockIndex"),
|
|
1089
|
+
text: record["text"],
|
|
1090
|
+
cascade: cascadeOf(record["cascade"]),
|
|
1091
|
+
...record["regenerate"] === void 0 ? {} : { regenerate: record["regenerate"] },
|
|
1092
|
+
...attachments === void 0 ? {} : { attachments },
|
|
1093
|
+
...settings === void 0 ? {} : { settings }
|
|
1094
|
+
};
|
|
1095
|
+
case "reroll": return {
|
|
1096
|
+
action: "reroll",
|
|
1097
|
+
sessionId
|
|
1098
|
+
};
|
|
1099
|
+
case "retry": return {
|
|
1100
|
+
action: "retry",
|
|
1101
|
+
sessionId,
|
|
1102
|
+
turn: integerOf(record["turn"], "turn"),
|
|
1103
|
+
cascade: cascadeOf(record["cascade"])
|
|
1104
|
+
};
|
|
1105
|
+
default: throw new TypeError("action 必须是 save、edit、reroll 或 retry。");
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function respondJson(status, value) {
|
|
1109
|
+
return Response.json(value, {
|
|
1110
|
+
status,
|
|
1111
|
+
headers: { "cache-control": "no-store" }
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
/** 提供节点身份及完整替换窗口,不返回正文,供其他插件解析修订链。 */
|
|
1115
|
+
async function revisions(ctx, sessionId) {
|
|
1116
|
+
const { events } = await ctx.sessionQuery.readSession(sessionId);
|
|
1117
|
+
const saved = savedUserMessages(events);
|
|
1118
|
+
const surface = foldSurface(events, ctx.sessions.messageProjections);
|
|
1119
|
+
return {
|
|
1120
|
+
schemaVersion: 1,
|
|
1121
|
+
sessionId,
|
|
1122
|
+
revisions: [...saved.entries()].map(([originalEventSeq, replacement]) => ({
|
|
1123
|
+
originalEventSeq,
|
|
1124
|
+
replacementEventSeq: replacement.seq,
|
|
1125
|
+
revisionEventSeqs: events.filter((event) => savedUserMessageSeq(event) === originalEventSeq).map((event) => event.seq),
|
|
1126
|
+
active: surface.nodes.includes(replacement.seq)
|
|
1127
|
+
})),
|
|
1128
|
+
replacements: surface.replacements.map((entry) => ({
|
|
1129
|
+
replacementEventSeq: entry.seq,
|
|
1130
|
+
startSeq: entry.start,
|
|
1131
|
+
endSeq: entry.end,
|
|
1132
|
+
shadowedEventSeqs: entry.shadowedSeqs
|
|
1133
|
+
}))
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
async function handleRoute(ctx, request, recent) {
|
|
1137
|
+
let action = "unknown";
|
|
1138
|
+
try {
|
|
1139
|
+
if (request.method === "GET") {
|
|
1140
|
+
const query = new URL(request.url).searchParams;
|
|
1141
|
+
if (query.get("view") === "diagnostics") return respondJson(200, {
|
|
1142
|
+
schemaVersion: 1,
|
|
1143
|
+
build: MESSAGE_EDIT_BUILD_INFO,
|
|
1144
|
+
recent: [...recent]
|
|
1145
|
+
});
|
|
1146
|
+
const sessionId = sessionIdOf(query.get("sessionId"));
|
|
1147
|
+
if (query.get("view") === "revisions") return respondJson(200, await revisions(ctx, sessionId));
|
|
1148
|
+
if (query.get("view") === "options") return respondJson(200, await composerOptions(ctx, sessionId, request.signal));
|
|
1149
|
+
if (query.get("view") === "references") return respondJson(200, await references(ctx, sessionId, query.get("query") ?? "", request.signal));
|
|
1150
|
+
return respondJson(200, await timeline(ctx, sessionId));
|
|
1151
|
+
}
|
|
1152
|
+
if (request.method === "POST") {
|
|
1153
|
+
let value;
|
|
1154
|
+
try {
|
|
1155
|
+
value = await request.json();
|
|
1156
|
+
} catch {
|
|
1157
|
+
throw new TypeError("请求体必须是有效的 JSON。");
|
|
1158
|
+
}
|
|
1159
|
+
const record = objectValue(value);
|
|
1160
|
+
if ([
|
|
1161
|
+
"save",
|
|
1162
|
+
"edit",
|
|
1163
|
+
"reroll",
|
|
1164
|
+
"retry",
|
|
1165
|
+
"open-attachment",
|
|
1166
|
+
"open-upload",
|
|
1167
|
+
"open-reference"
|
|
1168
|
+
].includes(String(record["action"]))) action = String(record["action"]);
|
|
1169
|
+
const sessionId = sessionIdOf(record["sessionId"]);
|
|
1170
|
+
if (record["action"] === "open-attachment") {
|
|
1171
|
+
await openAttachment(ctx, sessionId, integerOf(record["eventSeq"], "eventSeq"), integerOf(record["blockIndex"], "blockIndex"), request.signal);
|
|
1172
|
+
return respondJson(200, { opened: true });
|
|
1173
|
+
}
|
|
1174
|
+
if (record["action"] === "open-upload") {
|
|
1175
|
+
await openUpload(ctx, sessionId, nonEmptyString(record["receiptId"], "receiptId"), request.signal);
|
|
1176
|
+
return respondJson(200, { opened: true });
|
|
1177
|
+
}
|
|
1178
|
+
if (record["action"] === "open-reference") {
|
|
1179
|
+
await openReference(ctx, sessionId, nonEmptyString(record["path"], "path"), request.signal);
|
|
1180
|
+
return respondJson(200, { opened: true });
|
|
1181
|
+
}
|
|
1182
|
+
const result = await runOperation(ctx, decodeOperation(value));
|
|
1183
|
+
recent.push({
|
|
1184
|
+
time: Date.now(),
|
|
1185
|
+
action,
|
|
1186
|
+
status: 200,
|
|
1187
|
+
...result.unchanged === true ? { unchanged: true } : {}
|
|
1188
|
+
});
|
|
1189
|
+
if (recent.length > 40) recent.shift();
|
|
1190
|
+
return respondJson(200, result);
|
|
1191
|
+
}
|
|
1192
|
+
return new Response(null, { status: 405 });
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1195
|
+
const status = error instanceof TypeError ? 400 : 409;
|
|
1196
|
+
if (request.method === "POST") {
|
|
1197
|
+
recent.push({
|
|
1198
|
+
time: Date.now(),
|
|
1199
|
+
action,
|
|
1200
|
+
status,
|
|
1201
|
+
code: error instanceof TypeError ? "invalid-request" : "operation-failed"
|
|
1202
|
+
});
|
|
1203
|
+
if (recent.length > 40) recent.shift();
|
|
1204
|
+
}
|
|
1205
|
+
return respondJson(status, { error: message });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/** 通过宿主通信层注册路由,复用桌面端和浏览器的认证与传输。 */
|
|
1209
|
+
function apply(ctx) {
|
|
1210
|
+
const connection = ctx.get("connection");
|
|
1211
|
+
const recent = [];
|
|
1212
|
+
connection.fetch.register({
|
|
1213
|
+
path: MESSAGE_EDIT_PATH,
|
|
1214
|
+
methods: ["GET", "POST"],
|
|
1215
|
+
requestBody: "buffered",
|
|
1216
|
+
fetch: (request) => handleRoute(ctx, request, recent)
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
//#endregion
|
|
1220
|
+
export { MESSAGE_EDIT_BUILD_INFO, MESSAGE_EDIT_PATH, MESSAGE_EDIT_VERSION_SCHEMA, MESSAGE_EDIT_VIEW_ORDER, apply, inject, name };
|