@zaofan/dsh-qqbot 1.6.7 → 1.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/README_EN.md +5 -0
- package/client/qqbot-settings.js +5866 -5551
- package/dist/features/broadcast.d.ts.map +1 -1
- package/dist/features/broadcast.js +17 -5
- package/dist/features/broadcast.js.map +1 -1
- package/dist/features/sticker-store.d.ts +11 -0
- package/dist/features/sticker-store.d.ts.map +1 -1
- package/dist/features/sticker-store.js +26 -0
- package/dist/features/sticker-store.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +7 -0
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/middleware/sticker-capture.d.ts.map +1 -1
- package/dist/middleware/sticker-capture.js +10 -0
- package/dist/middleware/sticker-capture.js.map +1 -1
- package/docs/USER-GUIDE.md +903 -822
- package/package.json +1 -1
- package/settings-host.js +184 -0
package/package.json
CHANGED
package/settings-host.js
CHANGED
|
@@ -308,6 +308,37 @@ function route(ctx, method, path, handler) {
|
|
|
308
308
|
for (const m of methods) reg.handlers.set(m, handler);
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// ── workspace 会话重排:SSE 中枢(全进程单例)──────────────────────────────
|
|
312
|
+
// ⚠️ 2026-10-05 修复「watchers 恒为 0、前端永远收不到推送」:
|
|
313
|
+
// 该 SSE 端点原先写在 apply() 内、用裸 ctx.webServer.register 注册;而 route() 的按 path
|
|
314
|
+
// 去重是**模块级**的 ⇒ 多 bot 实例(im-qqbot-2 / im-qqbot-3 各 apply 一次)时,SSE handler
|
|
315
|
+
// 与 debug / test-reorder 的 handler 会落在**不同实例的闭包**里:浏览器把连接加进 A 实例的
|
|
316
|
+
// Set,debug 端点读的却是 B 实例的 Set ⇒ 永远 watchers:0,推送也发给了空集合。
|
|
317
|
+
// 实测铁证:curl 订阅 SSE 已收到 ': connected',同一时刻 debug 仍报 watchers:0。
|
|
318
|
+
// 改法:订阅集合/状态/注册全部收敛到 globalThis 单例 —— 无论模块被求值几次、apply 几次,
|
|
319
|
+
// 全进程只有一份,谁注册的 handler 都读写同一份。
|
|
320
|
+
const WS_HUB = (globalThis.__qqbotWorkspaceWatchHub || (globalThis.__qqbotWorkspaceWatchHub = {
|
|
321
|
+
watchers: new Set(),
|
|
322
|
+
last: { at: 0, sessionId: '' },
|
|
323
|
+
registered: false,
|
|
324
|
+
bound: false,
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
/** 向所有订阅中的浏览器推一条"某会话刚产出"(空 sessionId = 测试帧,让前端自己挑) */
|
|
328
|
+
function wsBroadcast(sessionId) {
|
|
329
|
+
WS_HUB.last = { at: Date.now(), sessionId: String(sessionId || '') };
|
|
330
|
+
const frames = [...WS_HUB.watchers];
|
|
331
|
+
if (!frames.length) return;
|
|
332
|
+
const isTest = !sessionId;
|
|
333
|
+
for (const r of frames) {
|
|
334
|
+
try {
|
|
335
|
+
r.write(isTest
|
|
336
|
+
? 'data: ' + JSON.stringify({ sessionId: null, test: true, at: Date.now() }) + '\n\n'
|
|
337
|
+
: 'data: ' + JSON.stringify({ sessionId: String(sessionId), at: Date.now() }) + '\n\n');
|
|
338
|
+
} catch { WS_HUB.watchers.delete(r); }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
311
342
|
export function apply(ctx) {
|
|
312
343
|
// ── 桥装载诊断(2026-09-10 排查路由全 404): 落 ~/.dsh/qqbot-bridge-diag.log ──
|
|
313
344
|
// 用途: 宿主重启后可确认 apply 是否真的被 cordis 调用(以及当时 ctx 上有哪些服务)。
|
|
@@ -2878,6 +2909,159 @@ export function apply(ctx) {
|
|
|
2878
2909
|
// 「🔄 重载模块」走 reload: 只清插件内存里的模块缓存(下次发卡/点击重新 import),
|
|
2879
2910
|
// **不需要重启宿主**, 也不需要重启插件 —— 这是本功能"改完即生效"的关键。
|
|
2880
2911
|
// 数据源: channel-tools 的 botplayExtStatus/reloadBotplayExt(只有插件侧才拿得到 controller)。
|
|
2912
|
+
// ── web 会话列表自动重排(2026-10-05 主人定)──────────────────────────
|
|
2913
|
+
// 需求:QQ 消息入站到某个 web 会话、且 LLM 真的产出了 ⇒ 把那个 web 会话在
|
|
2914
|
+
// 侧边栏列表里"上浮":从列表头往下数 n 个仍是"热的"(对话间隔<10min)会话,
|
|
2915
|
+
// 就插到第 n+1 位;若第 1 个就冷掉了(n=0) ⇒ 直接插第 1 位。
|
|
2916
|
+
// 置顶(「进行中」)的会话不参与计数、位置也不动。
|
|
2917
|
+
//
|
|
2918
|
+
// ⚠️ 分工:本端点只负责"告诉前端【哪个 dsh 会话刚刚活跃了】"——
|
|
2919
|
+
// 排序本身在浏览器端做(列表顺序是 browser-local 的 localStorage 状态,
|
|
2920
|
+
// 服务端改不了;DOM 那边由 client/qqbot-settings.js 改)。
|
|
2921
|
+
// ── web 会话列表自动重排(2026-10-05 主人定)────────────────────────────
|
|
2922
|
+
// 需求:QQ 消息入站到某个 web 会话、且 LLM 真的产出了 ⇒ 让那个 web 会话在
|
|
2923
|
+
// 侧边栏列表里"上浮":从列表头往下数 n 个仍是"热的"(对话间隔 < 10min)的会话,
|
|
2924
|
+
// 就插到第 n+1 位;若第 1 个就冷掉了(n=0)⇒ 直接插第 1 位。
|
|
2925
|
+
// 置顶(列表里的「进行中」)不参与计数、位置也不动。
|
|
2926
|
+
//
|
|
2927
|
+
// ⚠️ 分工:本端点只负责"告诉前端【哪个 dsh 会话刚刚活跃了】"。
|
|
2928
|
+
// 排序本身在浏览器端做 —— 列表顺序是 browser-local 的 localStorage 状态
|
|
2929
|
+
// (dsh.workspace.view.v5),服务端改不了;由 client/qqbot-settings.js 改。
|
|
2930
|
+
// ── web 会话列表自动重排(2026-10-05 主人定)────────────────────────────
|
|
2931
|
+
// 需求:QQ 消息入站到某个 web 会话、且 LLM 真的产出了 ⇒ 让那个 web 会话在
|
|
2932
|
+
// 侧边栏列表里"上浮":从列表头往下数 n 个仍是"热的"(对话间隔 < 10min)的会话,
|
|
2933
|
+
// 就插到第 n+1 位;若第 1 个就冷掉了(n=0)⇒ 直接插第 1 位。
|
|
2934
|
+
// 置顶(列表里的「进行中」)不参与计数、位置也不动。
|
|
2935
|
+
//
|
|
2936
|
+
// ⚠️ 分工:侧边栏顺序是 browser-local 的(localStorage["dsh.workspace.view.v5"]),
|
|
2937
|
+
// 服务端改不了 ⇒ 本端点只负责【给数据】:
|
|
2938
|
+
// · active —— 哪个 dsh 会话"刚刚"被激活了(插件侧 QQ↔dsh 映射)
|
|
2939
|
+
// · sessions —— 所有 dsh 会话的 { sessionId, at }(扫磁盘会话文件 mtime)
|
|
2940
|
+
// ⚠️ 这样浏览器端不必读 DOM、也不必解析"16小时"这种相对时间
|
|
2941
|
+
// · ready —— dsh 是否已就绪(没就绪前端不排序,见下)
|
|
2942
|
+
// ── web 会话列表自动重排(2026-10-05 主人定)────────────────────────────
|
|
2943
|
+
// 规则:QQ 消息入站到某个 web 会话、且 AI 真的产出了
|
|
2944
|
+
// ⇒ 从侧边栏列表头往下数 n 个仍"热的"(对话间隔 < 10min)会话,
|
|
2945
|
+
// 把目标会话插到第 n+1 位;若第 1 个就冷掉(n=0)⇒ 直接插第 1 位。
|
|
2946
|
+
// · 置顶(「进行中」)不参与计数、位置也不动。
|
|
2947
|
+
//
|
|
2948
|
+
// ★ 事件驱动,不轮询(2026-10-05 主人要求)——
|
|
2949
|
+
// 判据:"AI 真的产出了" = 该会话出现 assistant/message 事件。
|
|
2950
|
+
// 我们直接订阅宿主自己的 session/event(与出站层同一个事件源),
|
|
2951
|
+
// 命中就向订阅中的浏览器推一条 SSE;没人订阅 ⇒ 零开销、零扫描。
|
|
2952
|
+
//
|
|
2953
|
+
// 分工:侧边栏顺序是 browser-local 的 localStorage["dsh.workspace.view.v5"],
|
|
2954
|
+
// 服务端改不了 ⇒ 这里只"通知",排序在 client/qqbot-settings.js 里做。
|
|
2955
|
+
// ★ 全部走模块级单例 WS_HUB —— 跨 bot 实例共享(见文件顶部说明)
|
|
2956
|
+
const wsWatchers = WS_HUB.watchers;
|
|
2957
|
+
|
|
2958
|
+
// ★ 测试端点 1:手动触发一次广播(不用等 QQ 消息,专供调试)
|
|
2959
|
+
route(ctx, 'POST', '/api/qqbot-settings/workspace/test-reorder', async (req, res) => {
|
|
2960
|
+
try {
|
|
2961
|
+
let body = {};
|
|
2962
|
+
try { body = await readJsonBody(req); } catch { body = {}; }
|
|
2963
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
2964
|
+
wsBroadcast(sid);
|
|
2965
|
+
writeJson(res, 200, { ok: true, watchers: wsWatchers.size, pushed: sid || '(空: 让前端自己挑一个)' });
|
|
2966
|
+
} catch (e) {
|
|
2967
|
+
writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
|
|
2968
|
+
}
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
// ★ 测试端点 2:一眼看出「有没有浏览器在订阅、最后推了什么」
|
|
2972
|
+
route(ctx, 'GET', '/api/qqbot-settings/workspace/debug', async (req, res) => {
|
|
2973
|
+
try {
|
|
2974
|
+
writeJson(res, 200, { ok: true, watchers: WS_HUB.watchers.size, lastBroadcastAt: WS_HUB.last.at, lastSessionId: WS_HUB.last.sessionId, now: Date.now() });
|
|
2975
|
+
} catch (e) {
|
|
2976
|
+
writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
|
|
2977
|
+
}
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
// 订阅宿主会话事件:assistant/message = 这一轮 AI 真的说话了(判据见上)
|
|
2981
|
+
// ⚠️ 只绑一次(全局单例),否则两个 bot 实例会各推一帧、前端重复处理
|
|
2982
|
+
if (!WS_HUB.bound) {
|
|
2983
|
+
WS_HUB.bound = true;
|
|
2984
|
+
ctx.on('session/event', (session, event) => {
|
|
2985
|
+
try {
|
|
2986
|
+
if (!event || event.type !== 'assistant/message') return;
|
|
2987
|
+
const sid = String((session && (session.id || session.sessionId)) || '');
|
|
2988
|
+
if (!sid) return;
|
|
2989
|
+
wsBroadcast(sid);
|
|
2990
|
+
} catch { /* 兜底:绝不影响主链 */ }
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// ★ SSE 端点:绕过 route() helper(它是一次性请求/响应,不适合长连接),直接注册
|
|
2995
|
+
// ⚠️ 只注册一次(全局单例)—— 同 path 被两个 bot 实例各注册一次会互相覆盖,
|
|
2996
|
+
// 且 route() 的模块级去重管不到裸 register,正是 watchers 恒为 0 的根因。
|
|
2997
|
+
if (!WS_HUB.registered) {
|
|
2998
|
+
WS_HUB.registered = true;
|
|
2999
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3000
|
+
kind: 'exact',
|
|
3001
|
+
path: '/api/qqbot-settings/workspace/watch',
|
|
3002
|
+
handler: (req, res) => {
|
|
3003
|
+
try {
|
|
3004
|
+
if (!isTrusted(req)) { res.writeHead(403); res.end(); return; }
|
|
3005
|
+
res.writeHead(200, {
|
|
3006
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
3007
|
+
'cache-control': 'no-cache, no-transform',
|
|
3008
|
+
connection: 'keep-alive',
|
|
3009
|
+
'x-accel-buffering': 'no',
|
|
3010
|
+
});
|
|
3011
|
+
res.write(': connected\n\n'); // 立刻 flush,让浏览器认为连上了
|
|
3012
|
+
WS_HUB.watchers.add(res);
|
|
3013
|
+
const hb = setInterval(() => {
|
|
3014
|
+
try { res.write(': hb\n\n'); } catch { clearInterval(hb); WS_HUB.watchers.delete(res); }
|
|
3015
|
+
}, 25_000);
|
|
3016
|
+
req.on('close', () => { clearInterval(hb); WS_HUB.watchers.delete(res); });
|
|
3017
|
+
} catch { try { res.end(); } catch { /* ignore */ } }
|
|
3018
|
+
},
|
|
3019
|
+
}), 'qqbot-settings: workspace/watch');
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// ── 辅助端点(前端收到 SSE 后调一次,拿"各会话最后活动时间"来算 n)──
|
|
3023
|
+
// ⚠️ 只在这条路径上扫磁盘,且有 5 秒缓存 ⇒ 不再有定时扫描
|
|
3024
|
+
let wsSessionsCache = { at: 0, list: [] };
|
|
3025
|
+
function wsScanSessions() {
|
|
3026
|
+
const now = Date.now();
|
|
3027
|
+
if (now - wsSessionsCache.at < 5000) return wsSessionsCache.list;
|
|
3028
|
+
const out = [];
|
|
3029
|
+
try {
|
|
3030
|
+
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
3031
|
+
const root = home ? join(home, '.dsh', 'sessions') : '';
|
|
3032
|
+
if (root && existsSync(root)) {
|
|
3033
|
+
for (const wsDir of readdirSync(root)) {
|
|
3034
|
+
const wsPath = join(root, wsDir);
|
|
3035
|
+
let st1 = null;
|
|
3036
|
+
try { st1 = statSync(wsPath); } catch { continue; }
|
|
3037
|
+
if (!st1.isDirectory()) continue;
|
|
3038
|
+
for (const sid of readdirSync(wsPath)) {
|
|
3039
|
+
const sidPath = join(wsPath, sid);
|
|
3040
|
+
let newest = 0;
|
|
3041
|
+
try {
|
|
3042
|
+
for (const fn of readdirSync(sidPath)) {
|
|
3043
|
+
if (!/^session\.v\d+\.jsonl/.test(fn)) continue;
|
|
3044
|
+
try { const st = statSync(join(sidPath, fn)); if (st.mtimeMs > newest) newest = st.mtimeMs; } catch { /* skip */ }
|
|
3045
|
+
}
|
|
3046
|
+
} catch { continue; }
|
|
3047
|
+
if (newest > 0) out.push({ sessionId: sid, at: Math.round(newest) });
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
} catch { /* 扫不动就空表 */ }
|
|
3052
|
+
wsSessionsCache = { at: now, list: out };
|
|
3053
|
+
return out;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
route(ctx, 'GET', '/api/qqbot-settings/workspace/sessions', async (req, res) => {
|
|
3057
|
+
try {
|
|
3058
|
+
const list = wsScanSessions();
|
|
3059
|
+
writeJson(res, 200, { ok: true, ready: list.length > 0, sessions: list, at: Date.now() });
|
|
3060
|
+
} catch (e) {
|
|
3061
|
+
writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
|
|
3062
|
+
}
|
|
3063
|
+
});
|
|
3064
|
+
|
|
2881
3065
|
route(ctx, 'GET', '/api/qqbot-settings/group/botplay-ext/status', async (req, res) => {
|
|
2882
3066
|
try {
|
|
2883
3067
|
const u = new URL(req.url ?? '/', 'http://x');
|