@logictan/dsh-easyrewrite 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +215 -0
- package/README.ja.md +215 -0
- package/README.md +215 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +4281 -0
- package/lib/index.js +550 -0
- package/package.json +71 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-easyrewrite — node half。
|
|
3
|
+
*
|
|
4
|
+
* host 路由:
|
|
5
|
+
* - POST /bubble/recall { sessionId, targetSeq } → 撤回:
|
|
6
|
+
* 在 targetSeq 之前的最后一个闭合回合(turn/end)处 fork 新版本
|
|
7
|
+
* (新会话不含目标消息及其之后全部内容),flush 持久化。
|
|
8
|
+
* 「真正修改」只发生在这里;client 侧 pending 只是本地草稿态。
|
|
9
|
+
*
|
|
10
|
+
* 依赖服务:webServer(路由)、sessions(fork/flush)。
|
|
11
|
+
*/
|
|
12
|
+
import { appendFile, mkdir, writeFile, rm, readFile, readdir } from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
19
|
+
function settingsNamespace(value) {
|
|
20
|
+
if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------- 统一日志(host 落盘 $DSH_HOME/dsh-easyrewrite.log) ----------
|
|
25
|
+
let logFile = null;
|
|
26
|
+
const logBuffer = [];
|
|
27
|
+
let isFlushing = false;
|
|
28
|
+
const MAX_LOG_BUFFER = 1000; // 防御性上限:防止极端磁盘卡死时堆积内存
|
|
29
|
+
|
|
30
|
+
function resolveLogFile() {
|
|
31
|
+
if (logFile !== null) return logFile;
|
|
32
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
33
|
+
logFile = join(home, 'dsh-easyrewrite.log');
|
|
34
|
+
return logFile;
|
|
35
|
+
}
|
|
36
|
+
function ts() { return new Date().toISOString(); }
|
|
37
|
+
|
|
38
|
+
/** 批处理自驱式落盘(彻底杜绝无界 Promise 链与闭包泄漏,合并 IO 提升吞吐)。 */
|
|
39
|
+
async function flushLogBuffer() {
|
|
40
|
+
if (isFlushing) return;
|
|
41
|
+
isFlushing = true;
|
|
42
|
+
try {
|
|
43
|
+
const file = resolveLogFile();
|
|
44
|
+
await mkdir(dirname(file), { recursive: true });
|
|
45
|
+
|
|
46
|
+
while (logBuffer.length > 0) {
|
|
47
|
+
// 批量取出当前积攒的全部日志行
|
|
48
|
+
const batch = logBuffer.splice(0, logBuffer.length);
|
|
49
|
+
const content = batch.map((item) => item.line).join('\n') + '\n';
|
|
50
|
+
try {
|
|
51
|
+
await appendFile(file, content, 'utf8');
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// 单次 IO 异常不抛,避免破坏进程
|
|
54
|
+
}
|
|
55
|
+
for (let i = 0; i < batch.length; i++) {
|
|
56
|
+
batch[i].resolve();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// 目录创建或严重异常降级:清空当前积攒队列,释放等待的 Promise
|
|
61
|
+
while (logBuffer.length > 0) {
|
|
62
|
+
const item = logBuffer.shift();
|
|
63
|
+
item?.resolve();
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
isFlushing = false;
|
|
67
|
+
if (logBuffer.length > 0) {
|
|
68
|
+
flushLogBuffer().catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 串行化写入(按需批量落盘,支持 Promise 返回供外部 await,无无界闭包链)。 */
|
|
74
|
+
function writeLog(level, tag, message, data) {
|
|
75
|
+
const line = JSON.stringify({ t: ts(), level, tag, message, data: data ?? null });
|
|
76
|
+
// 默认静默(仅落盘);调试模式:环境变量 DSH_EASYREWRITE_DEBUG=1 时输出控制台
|
|
77
|
+
if (process.env.DSH_EASYREWRITE_DEBUG === '1') {
|
|
78
|
+
if (level === 'error') console.error('[dsh-easyrewrite]', tag, message, data ?? '');
|
|
79
|
+
else if (level === 'warn') console.warn('[dsh-easyrewrite]', tag, message, data ?? '');
|
|
80
|
+
else console.info('[dsh-easyrewrite]', tag, message, data ?? '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 容量上限防御:若积压超过 1000 条,丢弃最早日志,绝不把内存撑大
|
|
84
|
+
if (logBuffer.length >= MAX_LOG_BUFFER) {
|
|
85
|
+
const dropped = logBuffer.shift();
|
|
86
|
+
dropped?.resolve();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
logBuffer.push({ line, resolve });
|
|
91
|
+
flushLogBuffer().catch(() => {});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const name = 'dsh-easyrewrite'
|
|
96
|
+
export const inject = ['webServer', 'sessions', 'settings', 'agents']
|
|
97
|
+
|
|
98
|
+
/** 读取 JSON 请求体(带大小上限保护)。 */
|
|
99
|
+
function readJsonBody(req, limit = 1024 * 1024) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let size = 0;
|
|
103
|
+
req.on('data', (chunk) => {
|
|
104
|
+
size += chunk.length;
|
|
105
|
+
if (size > limit) {
|
|
106
|
+
// 超限:停止收集并继续消费 body(不 destroy——避免连接重置,handler 回 413)
|
|
107
|
+
req.removeAllListeners('data');
|
|
108
|
+
req.resume();
|
|
109
|
+
reject(new Error('body-too-large'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
chunks.push(chunk);
|
|
113
|
+
});
|
|
114
|
+
req.on('end', () => {
|
|
115
|
+
try {
|
|
116
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
117
|
+
resolve(text ? JSON.parse(text) : {});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
reject(err);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
req.on('error', reject);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- 路由安全层(review S1-S3):会话 id 白名单 / 同源校验 / JSON Content-Type ----------
|
|
127
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9-]+$/;
|
|
128
|
+
function validSessionId(value) {
|
|
129
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 128 && SESSION_ID_PATTERN.test(value);
|
|
130
|
+
}
|
|
131
|
+
/** 同源校验:请求必须来自本 GUI 页面(Origin/Referer 与 Host 匹配);无 Origin(同源/非浏览器)放行。 */
|
|
132
|
+
function requestFromSameOrigin(req) {
|
|
133
|
+
try {
|
|
134
|
+
const origin = req.headers?.origin || req.headers?.referer;
|
|
135
|
+
if (!origin) return true;
|
|
136
|
+
if (origin === 'null') return false; // sandbox iframe 拒绝
|
|
137
|
+
const host = req.headers?.host;
|
|
138
|
+
if (!host) return false;
|
|
139
|
+
const u = new URL(origin);
|
|
140
|
+
return u.host === host;
|
|
141
|
+
} catch { return false; }
|
|
142
|
+
}
|
|
143
|
+
function isJsonContentType(req) {
|
|
144
|
+
const ct = String(req.headers?.['content-type'] || '').toLowerCase();
|
|
145
|
+
return ct === 'application/json' || ct.startsWith('application/json;');
|
|
146
|
+
}
|
|
147
|
+
/** 统一路由守卫:同源 + JSON Content-Type + 可选 sessionId 白名单。通过返回 true。 */
|
|
148
|
+
function guard(req, res, needSessionId, sessionId) {
|
|
149
|
+
if (!requestFromSameOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden' }); return false; }
|
|
150
|
+
if (!isJsonContentType(req)) { sendJson(res, 415, { ok: false, error: 'unsupported-media-type' }); return false; }
|
|
151
|
+
if (needSessionId && !validSessionId(sessionId)) { sendJson(res, 400, { ok: false, error: 'invalid-session-id' }); return false; }
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- 插件自更新(精简版:检测 + 手动更新;自动检测默认关) ----------
|
|
156
|
+
async function readOwnVersion() {
|
|
157
|
+
try {
|
|
158
|
+
const here = fileURLToPath(import.meta.url);
|
|
159
|
+
const pkgPath = join(dirname(here), '..', 'package.json');
|
|
160
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
161
|
+
const pkg = JSON.parse(raw);
|
|
162
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
163
|
+
} catch (e) { return '0.0.0'; }
|
|
164
|
+
}
|
|
165
|
+
/** v2.4.0: 读取 dsh 宿主自身版本(从进程入口向上逐级找 @deepseek-ai/dsh 的 package.json)。 */
|
|
166
|
+
async function readDshVersion() {
|
|
167
|
+
const candidates = [];
|
|
168
|
+
// 路径1:进程入口向上逐级找
|
|
169
|
+
try {
|
|
170
|
+
let dir = path.dirname(process.argv[1] || "");
|
|
171
|
+
for (let i = 0; i < 6; i++) { candidates.push(join(dir, "package.json")); const up = path.dirname(dir); if (up === dir) break; dir = up; }
|
|
172
|
+
} catch (e) { /* ignore */ }
|
|
173
|
+
// 路径2:Windows npm 全局前缀兜底(rc.1 实测 argv 上探失败时)
|
|
174
|
+
try { const gp = process.env.APPDATA ? join(process.env.APPDATA, "npm", "node_modules", "@deepseek-ai", "dsh", "package.json") : null; if (gp) candidates.push(gp); } catch (e) { /* ignore */ }
|
|
175
|
+
for (const c of candidates) {
|
|
176
|
+
try {
|
|
177
|
+
const p = JSON.parse(await readFile(c, "utf8"));
|
|
178
|
+
if (p.name === "@deepseek-ai/dsh" && typeof p.version === "string") return p.version;
|
|
179
|
+
} catch (e) { /* 下一个候选 */ }
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchLatestVersion() {
|
|
185
|
+
try {
|
|
186
|
+
const resp = await fetch('https://registry.npmjs.org/@logictan%2Fdsh-easyrewrite/latest', {
|
|
187
|
+
headers: { 'user-agent': '@logictan/dsh-easyrewrite-update-check', accept: 'application/json' }
|
|
188
|
+
});
|
|
189
|
+
if (!resp.ok) return null;
|
|
190
|
+
const data = await resp.json();
|
|
191
|
+
return typeof data.version === 'string' ? data.version : null;
|
|
192
|
+
} catch (e) { return null; }
|
|
193
|
+
}
|
|
194
|
+
/** 定位安装本插件的运行目录(遍历 $DSH_HOME/profiles 下各目录的 package.json)。 */
|
|
195
|
+
async function findPluginHomeDir() {
|
|
196
|
+
try {
|
|
197
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
198
|
+
const profilesRoot = join(home, 'profiles');
|
|
199
|
+
const entries = await readdirSafe(profilesRoot);
|
|
200
|
+
for (const name of entries) {
|
|
201
|
+
const pkgPath = join(profilesRoot, name, 'package.json');
|
|
202
|
+
try {
|
|
203
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
204
|
+
if (raw.includes('@logictan/dsh-easyrewrite')) return join(profilesRoot, name);
|
|
205
|
+
} catch (e) { /* skip */ }
|
|
206
|
+
}
|
|
207
|
+
} catch (e) { /* ignore */ }
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
async function readdirSafe(dir) {
|
|
211
|
+
try { return await readdir(dir); } catch (e) { return []; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sendJson(res, status, obj) {
|
|
215
|
+
const body = JSON.stringify(obj);
|
|
216
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
|
|
217
|
+
res.end(body);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 目标事件之前最近的 turn/end seq;无则 -1。 */
|
|
221
|
+
function findTurnEndBefore(events, targetIdx) {
|
|
222
|
+
for (let i = targetIdx - 1; i >= 0; i--) {
|
|
223
|
+
if (events[i].type === 'turn/end') return events[i].seq;
|
|
224
|
+
}
|
|
225
|
+
return -1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 撤回边界解析:返回目标消息之前最后闭合回合(turn/end)的事件 seq。
|
|
230
|
+
* fork 由 client 端官方 RPC(ctx.sessions.fork)执行——child 才能进入会话列表。
|
|
231
|
+
* 无闭合回合(未结束回合内)→ turn-open。
|
|
232
|
+
*/
|
|
233
|
+
function resolveBoundary(ctx, sessionId, targetSeq) {
|
|
234
|
+
const session = ctx.sessions.get(sessionId);
|
|
235
|
+
if (!session) return { code: 'session-not-found', status: 404 };
|
|
236
|
+
// v2.4.0: dsh 0.1.2 起 Session.events 移除,改用 snapshotEvents(fromSeq, toSeqExclusive);
|
|
237
|
+
// rc.2 旧宿主仍走 session.events。事件对象两代同构(seq/type 字段)。
|
|
238
|
+
let events;
|
|
239
|
+
if (typeof session.snapshotEvents === "function") {
|
|
240
|
+
try { events = session.snapshotEvents(); } catch (e) { return { code: 'internal', status: 500, message: String(e?.message ?? e) }; }
|
|
241
|
+
} else {
|
|
242
|
+
events = session.events;
|
|
243
|
+
}
|
|
244
|
+
if (!Array.isArray(events)) return { code: 'internal', status: 500, message: 'events unavailable' };
|
|
245
|
+
const targetIdx = events.findIndex((e) => e.seq === targetSeq);
|
|
246
|
+
if (targetIdx === -1) {
|
|
247
|
+
return { code: 'invalid-target', status: 404, message: JSON.stringify({ targetSeq, eventsLen: events.length }) };
|
|
248
|
+
}
|
|
249
|
+
const boundary = findTurnEndBefore(events, targetIdx);
|
|
250
|
+
if (boundary === -1) {
|
|
251
|
+
// review M10:先判定目标之后是否有 turn/end(回合是否已闭合)——
|
|
252
|
+
// 已闭合但无前置边界(如首回合消息)→ no-boundary(诊断准确,不是 turn-open)
|
|
253
|
+
let hasLaterClose = false;
|
|
254
|
+
for (let i = targetIdx + 1; i < events.length; i++) {
|
|
255
|
+
if (events[i].type === 'turn/end') { hasLaterClose = true; break; }
|
|
256
|
+
}
|
|
257
|
+
if (hasLaterClose) {
|
|
258
|
+
return { code: 'no-boundary', status: 409, message: '该消息之前没有可截断的闭合回合边界(首条消息或跨回合场景)' };
|
|
259
|
+
}
|
|
260
|
+
return { code: 'turn-open', status: 409, message: '该消息所在回合尚未结束,无法截断;请等待回复完成(回合结束)后再撤回' };
|
|
261
|
+
}
|
|
262
|
+
return { boundary };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 物理拔除 fork 继承的幽灵队列项(Host 端直接操作 Agent.inbox)。
|
|
267
|
+
* DSH sessions.fork 会贪婪切入 boundary 到下一个 turn/start 之间的所有非回合事件,
|
|
268
|
+
* 导致旧消息的 agent/inbox/spliced 入队事件被子会话继承,而配对出队事件被截断,
|
|
269
|
+
* 在 Host 端 Agent.inbox.nextTurn 留下悬空旧消息。
|
|
270
|
+
* 此函数直接调用官方 agent.inbox.remove / clear 将其彻底移除并落盘 canceled 状态。
|
|
271
|
+
*/
|
|
272
|
+
function cleanGhostInbox(ctx, sessionId) {
|
|
273
|
+
let cleared = 0;
|
|
274
|
+
const removedIds = [];
|
|
275
|
+
try {
|
|
276
|
+
const agentsSvc = ctx?.agents || (typeof ctx?.get === 'function' ? ctx.get('agents') : null);
|
|
277
|
+
const agent = agentsSvc?.get ? agentsSvc.get(sessionId) : null;
|
|
278
|
+
if (agent && agent.inbox) {
|
|
279
|
+
const nextTurnItems = Array.isArray(agent.inbox.nextTurn) ? [...agent.inbox.nextTurn] : [];
|
|
280
|
+
const nextStepItems = Array.isArray(agent.inbox.nextStep) ? [...agent.inbox.nextStep] : [];
|
|
281
|
+
const allPending = [...nextTurnItems, ...nextStepItems];
|
|
282
|
+
|
|
283
|
+
for (const item of allPending) {
|
|
284
|
+
if (item && item.id) {
|
|
285
|
+
try {
|
|
286
|
+
const ok = typeof agent.inbox.remove === 'function' ? agent.inbox.remove(item.id) : false;
|
|
287
|
+
if (ok) {
|
|
288
|
+
cleared++;
|
|
289
|
+
removedIds.push(item.id);
|
|
290
|
+
}
|
|
291
|
+
} catch (eRem) {
|
|
292
|
+
writeLog('warn', 'clean-ghost', '移除单个幽灵消息失败', { id: item.id, err: String(eRem?.message ?? eRem) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 若仍有未清除的 pending,执行 clear() 终极清空
|
|
298
|
+
if (agent.inbox.hasPending && typeof agent.inbox.clear === 'function') {
|
|
299
|
+
try {
|
|
300
|
+
agent.inbox.clear();
|
|
301
|
+
cleared++;
|
|
302
|
+
} catch (eClr) {
|
|
303
|
+
writeLog('warn', 'clean-ghost', 'inbox.clear 异常', { err: String(eClr?.message ?? eClr) });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch (eAll) {
|
|
308
|
+
writeLog('warn', 'clean-ghost', 'cleanGhostInbox 执行异常', { sessionId, err: String(eAll?.message ?? eAll) });
|
|
309
|
+
}
|
|
310
|
+
return { cleared, removedIds };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 仅测试用:暴露内部纯函数(不参与运行时行为)。 */
|
|
314
|
+
export const __test = { findTurnEndBefore, resolveBoundary, cleanGhostInbox, writeLog, flushLogBuffer, getLogBuffer: () => logBuffer };
|
|
315
|
+
|
|
316
|
+
export function apply(ctx) {
|
|
317
|
+
writeLog('info', 'host', 'apply: 路由注册开始');
|
|
318
|
+
try {
|
|
319
|
+
if (ctx.settings && typeof ctx.settings.register === 'function') {
|
|
320
|
+
const dummySchema = (x) => x ?? {};
|
|
321
|
+
dummySchema.toJSON = () => ({ type: 'object' });
|
|
322
|
+
ctx.settings.register(settingsNamespace('dsh-easyrewrite'), dummySchema);
|
|
323
|
+
writeLog('info', 'host', 'settings namespace 已注册(插件配置卡片可用)');
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
writeLog('warn', 'host', 'settings namespace 注册失败(不影响核心功能)', { err: String(err?.message ?? err) });
|
|
327
|
+
}
|
|
328
|
+
const disposers = [];
|
|
329
|
+
// client 日志上报路由(统一甄别,落盘 $DSH_HOME/dsh-easyrewrite.log)
|
|
330
|
+
disposers.push(ctx.webServer.register({
|
|
331
|
+
kind: 'exact',
|
|
332
|
+
path: '/bubble/log',
|
|
333
|
+
handler: async (req, res) => {
|
|
334
|
+
try {
|
|
335
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
336
|
+
if (!guard(req, res, false)) return;
|
|
337
|
+
const body = await readJsonBody(req, 256 * 1024);
|
|
338
|
+
await writeLog(
|
|
339
|
+
typeof body.level === 'string' ? body.level : 'info',
|
|
340
|
+
typeof body.tag === 'string' ? body.tag : 'client',
|
|
341
|
+
typeof body.message === 'string' ? body.message : '',
|
|
342
|
+
body.data
|
|
343
|
+
);
|
|
344
|
+
sendJson(res, 200, { ok: true });
|
|
345
|
+
} catch (err) {
|
|
346
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}));
|
|
350
|
+
// 草稿自动备份(覆盖式):写 $DSH_HOME/dsh-easyrewrite/backups/<sessionId>.json
|
|
351
|
+
function backupPath(sessionId) {
|
|
352
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
353
|
+
return join(home, 'dsh-easyrewrite', 'backups', sessionId + '.json');
|
|
354
|
+
}
|
|
355
|
+
disposers.push(ctx.webServer.register({
|
|
356
|
+
kind: 'exact',
|
|
357
|
+
path: '/bubble/backup',
|
|
358
|
+
handler: async (req, res) => {
|
|
359
|
+
try {
|
|
360
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false }); return; }
|
|
361
|
+
const body = await readJsonBody(req, 512 * 1024);
|
|
362
|
+
const { sessionId, pending } = body;
|
|
363
|
+
if (!guard(req, res, true, sessionId) || !pending || typeof pending !== 'object') { sendJson(res, 400, { ok: false, error: 'invalid-request' }); return; }
|
|
364
|
+
const file = backupPath(sessionId);
|
|
365
|
+
await mkdir(dirname(file), { recursive: true });
|
|
366
|
+
await writeFile(file, JSON.stringify(pending, null, 2), 'utf8'); // 覆盖式
|
|
367
|
+
sendJson(res, 200, { ok: true });
|
|
368
|
+
} catch (err) {
|
|
369
|
+
writeLog('warn', 'backup', '备份写入失败', { err: String(err?.message ?? err) });
|
|
370
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
disposers.push(ctx.webServer.register({
|
|
375
|
+
kind: 'exact',
|
|
376
|
+
path: '/bubble/backup/read',
|
|
377
|
+
handler: async (req, res) => {
|
|
378
|
+
try {
|
|
379
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false }); return; }
|
|
380
|
+
const body = await readJsonBody(req);
|
|
381
|
+
const { sessionId } = body;
|
|
382
|
+
if (!guard(req, res, true, sessionId)) { return; }
|
|
383
|
+
try {
|
|
384
|
+
const raw = await readFile(backupPath(sessionId), 'utf8');
|
|
385
|
+
const pending = JSON.parse(raw);
|
|
386
|
+
sendJson(res, 200, { ok: true, pending });
|
|
387
|
+
} catch (e) {
|
|
388
|
+
sendJson(res, 200, { ok: true, pending: null }); // 无备份
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}));
|
|
395
|
+
disposers.push(ctx.webServer.register({
|
|
396
|
+
kind: 'exact',
|
|
397
|
+
path: '/bubble/backup/delete',
|
|
398
|
+
handler: async (req, res) => {
|
|
399
|
+
try {
|
|
400
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false }); return; }
|
|
401
|
+
const body = await readJsonBody(req);
|
|
402
|
+
const { sessionId } = body;
|
|
403
|
+
if (!guard(req, res, true, sessionId)) { return; }
|
|
404
|
+
await rm(backupPath(sessionId), { force: true });
|
|
405
|
+
sendJson(res, 200, { ok: true });
|
|
406
|
+
} catch (err) {
|
|
407
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}));
|
|
411
|
+
// 版本翻页器:恢复归档会话(幂等——未归档时为 no-op)。官方无 unarchive API,
|
|
412
|
+
// 通过 workspaceRegistry 实例的排队操作把 sessionId 从归档集合移除。
|
|
413
|
+
disposers.push(ctx.webServer.register({
|
|
414
|
+
kind: 'exact',
|
|
415
|
+
path: '/bubble/unarchive',
|
|
416
|
+
handler: async (req, res) => {
|
|
417
|
+
try {
|
|
418
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
419
|
+
const body = await readJsonBody(req);
|
|
420
|
+
const { sessionId } = body;
|
|
421
|
+
if (!guard(req, res, true, sessionId)) { return; }
|
|
422
|
+
let registry = null;
|
|
423
|
+
try { registry = ctx.get('workspaceRegistry'); } catch (e) { /* service absent */ }
|
|
424
|
+
if (!registry || typeof registry.enqueueOperation !== 'function' || typeof registry.requireState !== 'function' || typeof registry.setState !== 'function') {
|
|
425
|
+
writeLog('warn', 'host', 'unarchive: workspaceRegistry 不可用');
|
|
426
|
+
sendJson(res, 200, { ok: false, error: 'registry-unavailable' });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// review S3:存在性校验(与官方 archiveSession 的 sessionKnown 对齐)
|
|
430
|
+
if (typeof registry.sessionKnown === 'function') {
|
|
431
|
+
const known = await registry.sessionKnown(sessionId);
|
|
432
|
+
if (!known) { sendJson(res, 404, { ok: false, error: 'session-not-found' }); return; }
|
|
433
|
+
}
|
|
434
|
+
const restored = await registry.enqueueOperation(async () => {
|
|
435
|
+
const state = registry.requireState();
|
|
436
|
+
const next = state.archivedSessionIds.filter((id) => id !== sessionId);
|
|
437
|
+
if (next.length === state.archivedSessionIds.length) return false; // 未归档
|
|
438
|
+
await registry.setState({ ...state, archivedSessionIds: next });
|
|
439
|
+
return true;
|
|
440
|
+
});
|
|
441
|
+
writeLog('info', 'host', 'unarchive 完成', { sessionId, restored });
|
|
442
|
+
sendJson(res, 200, { ok: true, restored });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
writeLog('warn', 'host', 'unarchive 失败', { err: String(err?.message ?? err) });
|
|
445
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}));
|
|
449
|
+
// 撤回路由
|
|
450
|
+
disposers.push(ctx.webServer.register({
|
|
451
|
+
kind: 'exact',
|
|
452
|
+
path: '/bubble/recall',
|
|
453
|
+
handler: async (req, res) => {
|
|
454
|
+
try {
|
|
455
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
456
|
+
const body = await readJsonBody(req);
|
|
457
|
+
const { sessionId, targetSeq } = body;
|
|
458
|
+
if (!guard(req, res, true, sessionId) || typeof targetSeq !== 'number' || !Number.isFinite(targetSeq)) {
|
|
459
|
+
sendJson(res, 400, { ok: false, error: 'invalid-request' });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
writeLog('info', 'recall', '收到撤回边界请求', { sessionId, targetSeq });
|
|
463
|
+
const result = resolveBoundary(ctx, sessionId, targetSeq);
|
|
464
|
+
if (result.code) {
|
|
465
|
+
writeLog('warn', 'recall', '撤回被拒绝: ' + result.code, { sessionId, targetSeq, message: result.message });
|
|
466
|
+
sendJson(res, result.status, { ok: false, error: result.code, message: result.message });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// fork 由 client 官方 RPC 执行(child 才能进会话列表并可打开)
|
|
470
|
+
writeLog('info', 'recall', '边界就绪', { sessionId, targetSeq, boundary: result.boundary });
|
|
471
|
+
sendJson(res, 200, { ok: true, boundary: result.boundary });
|
|
472
|
+
} catch (err) {
|
|
473
|
+
writeLog('error', 'recall', '/bubble/recall 异常', { message: String(err?.message ?? err) });
|
|
474
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}));
|
|
478
|
+
// 幽灵队列清理路由:清除由 fork 继承的悬空旧消息(Issue #10 彻底根治)
|
|
479
|
+
disposers.push(ctx.webServer.register({
|
|
480
|
+
kind: 'exact',
|
|
481
|
+
path: '/bubble/clean-ghost',
|
|
482
|
+
handler: async (req, res) => {
|
|
483
|
+
try {
|
|
484
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
485
|
+
const body = await readJsonBody(req);
|
|
486
|
+
const { sessionId } = body;
|
|
487
|
+
if (!guard(req, res, true, sessionId)) return;
|
|
488
|
+
|
|
489
|
+
writeLog('info', 'clean-ghost', '收到幽灵队列清理请求', { sessionId });
|
|
490
|
+
const result = cleanGhostInbox(ctx, sessionId);
|
|
491
|
+
writeLog('info', 'clean-ghost', 'Host 端幽灵队列清理完成', { sessionId, cleared: result.cleared, removedIds: result.removedIds });
|
|
492
|
+
sendJson(res, 200, { ok: true, cleared: result.cleared, removedIds: result.removedIds });
|
|
493
|
+
} catch (err) {
|
|
494
|
+
writeLog('error', 'clean-ghost', '/bubble/clean-ghost 异常', { message: String(err?.message ?? err) });
|
|
495
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}));
|
|
499
|
+
// 插件自更新:检查 npm 最新版本(只读)
|
|
500
|
+
disposers.push(ctx.webServer.register({
|
|
501
|
+
kind: 'exact',
|
|
502
|
+
path: '/bubble/check-update',
|
|
503
|
+
handler: async (req, res) => {
|
|
504
|
+
try {
|
|
505
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
506
|
+
if (!guard(req, res, false)) return;
|
|
507
|
+
const body = await readJsonBody(req, 16 * 1024);
|
|
508
|
+
const current = await readOwnVersion();
|
|
509
|
+
const latest = await fetchLatestVersion();
|
|
510
|
+
const dshVersion = await readDshVersion();
|
|
511
|
+
writeLog('info', 'host', 'check-update', { current, latest, dshVersion });
|
|
512
|
+
sendJson(res, 200, { ok: true, current, latest, dshVersion });
|
|
513
|
+
} catch (err) {
|
|
514
|
+
writeLog('warn', 'host', 'check-update 失败', { err: String(err?.message ?? err) });
|
|
515
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}));
|
|
519
|
+
// 插件自更新:执行 pnpm up(写操作——client 端必须用户显式确认后调用)
|
|
520
|
+
disposers.push(ctx.webServer.register({
|
|
521
|
+
kind: 'exact',
|
|
522
|
+
path: '/bubble/update-plugin',
|
|
523
|
+
handler: async (req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
526
|
+
if (!guard(req, res, false)) return;
|
|
527
|
+
const body = await readJsonBody(req, 16 * 1024);
|
|
528
|
+
const profileDir = await findPluginHomeDir();
|
|
529
|
+
if (!profileDir) { sendJson(res, 200, { ok: false, error: 'profile-not-found' }); return; }
|
|
530
|
+
writeLog('info', 'host', 'update-plugin 开始', { profileDir });
|
|
531
|
+
const output = await new Promise((resolve) => {
|
|
532
|
+
execFile('pnpm', ['up', '@logictan/dsh-easyrewrite'], { cwd: profileDir, timeout: 90000, windowsHide: true }, (err, stdout, stderr) => {
|
|
533
|
+
resolve({ err: err ? String(err.message || err) : null, stdout: String(stdout || '').slice(-1500), stderr: String(stderr || '').slice(-1500) });
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
const ok = !output.err || output.stdout.includes('up to date') || output.stdout.includes('Done');
|
|
537
|
+
writeLog('info', 'host', 'update-plugin 结果', { ok, err: output.err, outTail: output.stdout.slice(-200) });
|
|
538
|
+
sendJson(res, 200, { ok, output: output.stdout.slice(-800) });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
writeLog('warn', 'host', 'update-plugin 失败', { err: String(err?.message ?? err) });
|
|
541
|
+
sendJson(res, 500, { ok: false, error: 'internal' });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}));
|
|
545
|
+
writeLog('info', 'host', 'apply: 路由注册完成');
|
|
546
|
+
return () => {
|
|
547
|
+
for (const d of disposers) { try { d(); } catch (e) { /* ignore */ } }
|
|
548
|
+
writeLog('info', 'host', 'apply: 已卸载');
|
|
549
|
+
};
|
|
550
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@logictan/dsh-easyrewrite",
|
|
3
|
+
"version": "2.5.4",
|
|
4
|
+
"description": "DSH Web内目前最无感的消息撤回、重编辑插件,原版体验,兼容性强,功能简单可开关,设置丰富,现代化轻量ui框架。The most seamless message recall & re-edit plugin for DSH Web — native experience, strong compatibility, simple toggles, rich settings, modern lightweight UI. DSH Web で最もシームレスなメッセージ撤回・再編集プラグイン——ネイティブ体験、高い互換性、シンプルなトグル、充実した設定、モダンで軽量な UI。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"bundle": {
|
|
14
|
+
"patch": "./cordis.patch.yml"
|
|
15
|
+
},
|
|
16
|
+
"client": {
|
|
17
|
+
"inject": [
|
|
18
|
+
"slots",
|
|
19
|
+
"sessions",
|
|
20
|
+
"workspaces"
|
|
21
|
+
],
|
|
22
|
+
"platform": "web"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"lib",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md",
|
|
29
|
+
"README.en.md",
|
|
30
|
+
"README.ja.md"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"dsh",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"client-plugin",
|
|
36
|
+
"ui",
|
|
37
|
+
"plugin",
|
|
38
|
+
"edit",
|
|
39
|
+
"rewrite",
|
|
40
|
+
"bubble-edit",
|
|
41
|
+
"recall",
|
|
42
|
+
"undo",
|
|
43
|
+
"rollback",
|
|
44
|
+
"version-pager",
|
|
45
|
+
"撤回",
|
|
46
|
+
"气泡编辑",
|
|
47
|
+
"重编辑",
|
|
48
|
+
"取り消し",
|
|
49
|
+
"バブル編集",
|
|
50
|
+
"編集",
|
|
51
|
+
"i18n",
|
|
52
|
+
"internationalization",
|
|
53
|
+
"multilingual",
|
|
54
|
+
"localization",
|
|
55
|
+
"zh",
|
|
56
|
+
"en",
|
|
57
|
+
"ja"
|
|
58
|
+
],
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"scripts": {
|
|
61
|
+
"test": "node tests/smoke-host.mjs && node tests/test-log-memory.mjs && node tests/test-issue9-anti-double-submit.mjs",
|
|
62
|
+
"build": "node build.mjs",
|
|
63
|
+
"prepare": "node build.mjs",
|
|
64
|
+
"prepack": "node build.mjs"
|
|
65
|
+
},
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "git+https://github.com/dale0525/dsh-plugins.git",
|
|
69
|
+
"directory": "packages/dsh-easyrewrite"
|
|
70
|
+
}
|
|
71
|
+
}
|