@sidleo3/dsh-chat 0.0.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.
Files changed (47) hide show
  1. package/client/bot-list.js +243 -0
  2. package/client/bot-settings.js +175 -0
  3. package/client/bot-shared-settings.js +561 -0
  4. package/client/chat-ui.js +134 -0
  5. package/client/context-enhancement.js +435 -0
  6. package/client/delivery-targets.js +334 -0
  7. package/client/diagnostics.js +160 -0
  8. package/client/i18n.js +371 -0
  9. package/client/index.js +77 -0
  10. package/client/list-order.js +144 -0
  11. package/client/rpc.js +52 -0
  12. package/client/scoped-mode-editor.js +111 -0
  13. package/client/section.js +250 -0
  14. package/client/session-badges.js +263 -0
  15. package/client/styles.js +960 -0
  16. package/client/version-panel.js +97 -0
  17. package/cordis.patch.yml +5 -0
  18. package/host/bot-model.mjs +53 -0
  19. package/host/bot-settings.mjs +247 -0
  20. package/host/channel-registry.mjs +237 -0
  21. package/host/commands.mjs +857 -0
  22. package/host/deferred.mjs +291 -0
  23. package/host/delivery.mjs +377 -0
  24. package/host/file-log.mjs +169 -0
  25. package/host/guidance.mjs +73 -0
  26. package/host/index.mjs +7 -0
  27. package/host/interactions.mjs +330 -0
  28. package/host/json-store.mjs +144 -0
  29. package/host/log-tail.mjs +63 -0
  30. package/host/panel.mjs +1012 -0
  31. package/host/paths.mjs +50 -0
  32. package/host/plugin.mjs +873 -0
  33. package/host/prompt-context.mjs +70 -0
  34. package/host/rpc.mjs +147 -0
  35. package/host/session-keys.mjs +25 -0
  36. package/host/session-store.mjs +187 -0
  37. package/host/sessions.mjs +1348 -0
  38. package/host/tools.mjs +283 -0
  39. package/lib/client.js +4431 -0
  40. package/lib/index.js +5676 -0
  41. package/package.json +63 -0
  42. package/shared/access-policy.mjs +263 -0
  43. package/shared/channel-rail.mjs +156 -0
  44. package/shared/context-enhancement.mjs +415 -0
  45. package/shared/contract.mjs +120 -0
  46. package/shared/panel-sections.mjs +76 -0
  47. package/shared/reply-reference.mjs +115 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 通用 JSON 文档存储:原子写 + 首次覆盖备份 + 串行写入队列 + 变更订阅。
3
+ *
4
+ * hub 的每个持久化文档(每机器人设置、会话绑定)都复用它,避免把同一套
5
+ * 写盘纪律实现多遍。
6
+ *
7
+ * @module dsh-chat/host/json-store
8
+ */
9
+
10
+ import { randomBytes } from 'node:crypto';
11
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
12
+ import { dirname } from 'node:path';
13
+
14
+ /**
15
+ * 创建 JSON 文档存储。
16
+ *
17
+ * @param options - {
18
+ * path, // 文件绝对路径
19
+ * normalize(raw), // 把任意磁盘内容折成合法文档(必须容错)
20
+ * empty(), // 文件不存在时的初始文档
21
+ * logger,
22
+ * label, // 日志前缀,如 '每机器人设置'
23
+ * }。
24
+ * @returns { path, ready, snapshot, read, update, subscribe }。
25
+ */
26
+ export function createJsonStore({
27
+ path,
28
+ normalize,
29
+ empty,
30
+ logger = console,
31
+ label = 'JSON 文档',
32
+ }) {
33
+ if (typeof path !== 'string' || !path.trim()) throw new TypeError('json store 需要 path。');
34
+ if (typeof normalize !== 'function') throw new TypeError('json store 需要 normalize。');
35
+ if (typeof empty !== 'function') throw new TypeError('json store 需要 empty。');
36
+
37
+ let document = normalize(empty());
38
+ let loaded = false;
39
+ let loading = null;
40
+ let queue = Promise.resolve();
41
+ let backedUp = false;
42
+ const listeners = new Set();
43
+
44
+ function notify() {
45
+ for (const listener of [...listeners]) {
46
+ try {
47
+ listener(document);
48
+ } catch {
49
+ // 单个订阅者出错不影响其他订阅者。
50
+ }
51
+ }
52
+ }
53
+
54
+ async function persist() {
55
+ const body = `${JSON.stringify(document, null, 2)}\n`;
56
+ await mkdir(dirname(path), { recursive: true });
57
+ if (!backedUp) {
58
+ try {
59
+ const previous = await readFile(path, 'utf8');
60
+ if (previous.trim()) {
61
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
62
+ await writeFile(`${path}.bak-${stamp}`, previous, 'utf8');
63
+ // 只有真的备份成功才算数:首次写入(还没有旧文件)留给下一次真正覆盖时备份。
64
+ backedUp = true;
65
+ }
66
+ } catch {
67
+ // 没有旧文件(或读不到)就没有可备份的内容。
68
+ }
69
+ }
70
+ const temporary = `${path}.tmp-${randomBytes(6).toString('hex')}`;
71
+ await writeFile(temporary, body, 'utf8');
72
+ await rename(temporary, path);
73
+ }
74
+
75
+ function enqueue(task) {
76
+ const next = queue.then(task, task);
77
+ queue = next.then(() => undefined, () => undefined);
78
+ return next;
79
+ }
80
+
81
+ async function load() {
82
+ if (loaded) return document;
83
+ try {
84
+ document = normalize(JSON.parse(await readFile(path, 'utf8')));
85
+ } catch (error) {
86
+ if (error?.code !== 'ENOENT') {
87
+ logger.warn?.(`[dsh-chat] 读取 ${path} 失败,使用空${label}:${error?.message ?? error}`);
88
+ }
89
+ document = normalize(empty());
90
+ }
91
+ loaded = true;
92
+ return document;
93
+ }
94
+
95
+ return {
96
+ path,
97
+
98
+ /** 等磁盘文档就绪(并发多次调用只读一次盘)。 */
99
+ async ready() {
100
+ if (loaded) return document;
101
+ loading = loading ?? load();
102
+ return loading;
103
+ },
104
+
105
+ /** @returns 当前文档。 */
106
+ snapshot() {
107
+ return document;
108
+ },
109
+
110
+ /**
111
+ * 串行地读-改-写。
112
+ *
113
+ * @param updater - `(current) => next | null`;返回 null 表示不写盘。
114
+ * @returns 写入后的文档。
115
+ */
116
+ async update(updater) {
117
+ return enqueue(async () => {
118
+ await this.ready();
119
+ const next = updater(document);
120
+ if (next === null || next === undefined) return document;
121
+ document = normalize(next);
122
+ await persist();
123
+ notify();
124
+ return document;
125
+ });
126
+ },
127
+
128
+ /** 等待已排队的写入落定(停机前调用,避免和进程退出抢时间)。 */
129
+ async flush() {
130
+ await queue;
131
+ },
132
+
133
+ /**
134
+ * 订阅文档变更(写入成功后触发)。
135
+ *
136
+ * @param listener - `(document) => void`。
137
+ * @returns 取消订阅函数。
138
+ */
139
+ subscribe(listener) {
140
+ listeners.add(listener);
141
+ return () => listeners.delete(listener);
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 读日志文件尾部(诊断面板用)。
3
+ *
4
+ * 为什么按字节从尾部读:日志上限 2MB,而诊断要看的是"刚刚发生了什么"。
5
+ * 整读会把内存和浏览器负载都推高,还得把整个文件传给前端。
6
+ *
7
+ * 约束:**读日志绝不能成为新的故障源**——文件不存在、权限不足、被轮转掉,
8
+ * 都返回 `exists: false` 而不是抛错(诊断面板要能照常显示其它部分)。
9
+ *
10
+ * @module dsh-chat/host/log-tail
11
+ */
12
+
13
+ import { open, stat } from 'node:fs/promises';
14
+
15
+ /** 默认只读最后 16KB(约等于几十行),够看现场又不至于把面板撑爆。 */
16
+ const DEFAULT_MAX_BYTES = 16 * 1024;
17
+ const DEFAULT_MAX_LINES = 40;
18
+
19
+ /**
20
+ * 读一个日志文件的最后若干行。
21
+ *
22
+ * @param path - 日志文件绝对路径。
23
+ * @param options - { maxBytes, maxLines }。
24
+ * @returns `{ path, exists, size, modifiedAt, lines }`;读不到时 `exists: false`。
25
+ */
26
+ export async function readLogTail(path, {
27
+ maxBytes = DEFAULT_MAX_BYTES,
28
+ maxLines = DEFAULT_MAX_LINES,
29
+ } = {}) {
30
+ const empty = { path, exists: false, size: 0, modifiedAt: null, lines: [] };
31
+ let info;
32
+ try {
33
+ info = await stat(path);
34
+ if (!info.isFile()) return empty;
35
+ } catch {
36
+ return empty;
37
+ }
38
+ const length = Math.min(maxBytes, info.size);
39
+ const start = Math.max(0, info.size - length);
40
+ let text = '';
41
+ try {
42
+ const handle = await open(path, 'r');
43
+ try {
44
+ const buffer = Buffer.alloc(length);
45
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
46
+ text = buffer.subarray(0, bytesRead).toString('utf8');
47
+ } finally {
48
+ await handle.close();
49
+ }
50
+ } catch {
51
+ return empty;
52
+ }
53
+ const raw = text.split('\n');
54
+ // 从中间截断时第一行是半截的(也可能是多字节字符的残片):丢掉。
55
+ if (start > 0) raw.shift();
56
+ return {
57
+ path,
58
+ exists: true,
59
+ size: info.size,
60
+ modifiedAt: info.mtime.toISOString(),
61
+ lines: raw.filter((line) => line.trim() !== '').slice(-maxLines),
62
+ };
63
+ }