@ondevplann/remotetype 0.1.2 → 0.2.0

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/lib/queue.js CHANGED
@@ -1,141 +1,141 @@
1
- 'use strict';
2
-
3
- /**
4
- * FIFO 消息队列:串行消费,保证「一条输入完成再处理下一条」(需求文档 4.2.2 第 2 条)。
5
- *
6
- * 要点:
7
- * * 严格 FIFO,绝不并发下发,避免字符交叉乱序;
8
- * * 有界队列,超出容量时丢弃新消息并告警(丢弃新消息而非旧消息,
9
- * 因为旧消息已经在途、语义上更接近用户已完成的操作);
10
- * * 对外暴露 pause / resume,便于断线或重启子进程时挂起消费。
11
- */
12
-
13
- class MessageQueue {
14
- /**
15
- * @param {object} opts
16
- * @param {number} [opts.maxSize] 最大排队条数
17
- * @param {Function} opts.handler 消费函数 async (item, meta) => void
18
- * @param {object} opts.logger
19
- */
20
- constructor(opts) {
21
- this.maxSize = opts.maxSize ?? 100;
22
- this.handler = opts.handler;
23
- this.logger = opts.logger;
24
-
25
- this._queue = [];
26
- this._running = false;
27
- this._paused = false;
28
- this._idleWaiters = [];
29
-
30
- this.stats = { enqueued: 0, processed: 0, failed: 0, dropped: 0, maxDepthSeen: 0 };
31
- }
32
-
33
- /** 当前排队深度(不含正在处理的那条)。 */
34
- get depth() {
35
- return this._queue.length;
36
- }
37
-
38
- get busy() {
39
- return this._running;
40
- }
41
-
42
- /**
43
- * 入队一条消息。
44
- * @returns {boolean} true=已入队,false=因队列满被丢弃
45
- */
46
- push(item, meta = {}) {
47
- if (this._queue.length >= this.maxSize) {
48
- this.stats.dropped += 1;
49
- this.logger?.warn('message queue overflow, dropping new message', {
50
- maxSize: this.maxSize,
51
- textLen: typeof item?.text === 'string' ? item.text.length : 0,
52
- });
53
- return false;
54
- }
55
- this._queue.push({ item, meta });
56
- this.stats.enqueued += 1;
57
- if (this._queue.length > this.stats.maxDepthSeen) {
58
- this.stats.maxDepthSeen = this._queue.length;
59
- }
60
- this.logger?.debug('message enqueued', { depth: this._queue.length });
61
- // 触发消费(不 await,避免调用方被阻塞)
62
- this._drain();
63
- return true;
64
- }
65
-
66
- /** 暂停消费:已在途的那条会跑完,后续不再取新消息。 */
67
- pause() {
68
- this._paused = true;
69
- this.logger?.debug('queue paused');
70
- }
71
-
72
- /** 恢复消费。 */
73
- resume() {
74
- if (!this._paused) return;
75
- this._paused = false;
76
- this.logger?.debug('queue resumed', { depth: this._queue.length });
77
- this._drain();
78
- }
79
-
80
- /** 清空队列,返回被清掉的条数。 */
81
- clear(reason = 'cleared') {
82
- const n = this._queue.length;
83
- this._queue = [];
84
- if (n > 0) this.logger?.warn('queue cleared', { count: n, reason });
85
- return n;
86
- }
87
-
88
- /** 等待队列彻底空闲(含正在处理的那条)。 */
89
- async waitIdle(timeoutMs = 30000) {
90
- if (!this._running && this._queue.length === 0) return true;
91
- return new Promise((resolve) => {
92
- const timer = setTimeout(() => resolve(false), timeoutMs);
93
- timer.unref?.();
94
- this._idleWaiters.push(() => {
95
- clearTimeout(timer);
96
- resolve(true);
97
- });
98
- });
99
- }
100
-
101
- _notifyIdle() {
102
- if (this._running || this._queue.length > 0) return;
103
- const waiters = this._idleWaiters;
104
- this._idleWaiters = [];
105
- for (const w of waiters) w();
106
- }
107
-
108
- /** 串行消费主循环。 */
109
- async _drain() {
110
- if (this._running) return; // 已有消费者在跑,保证只有一条在途
111
- if (this._paused) return;
112
- if (this._queue.length === 0) {
113
- this._notifyIdle();
114
- return;
115
- }
116
-
117
- this._running = true;
118
- try {
119
- while (!this._paused && this._queue.length > 0) {
120
- const { item, meta } = this._queue.shift();
121
- try {
122
- // 顺序 await:这是「串行」的关键
123
- await this.handler(item, meta);
124
- this.stats.processed += 1;
125
- } catch (e) {
126
- this.stats.failed += 1;
127
- this.logger?.error('queue handler failed', {
128
- error: e.message,
129
- textLen: typeof item?.text === 'string' ? item.text.length : 0,
130
- });
131
- // 单条失败不影响后续消息
132
- }
133
- }
134
- } finally {
135
- this._running = false;
136
- }
137
- this._notifyIdle();
138
- }
139
- }
140
-
141
- module.exports = { MessageQueue };
1
+ 'use strict';
2
+
3
+ /**
4
+ * FIFO 消息队列:串行消费,保证「一条输入完成再处理下一条」(需求文档 4.2.2 第 2 条)。
5
+ *
6
+ * 要点:
7
+ * * 严格 FIFO,绝不并发下发,避免字符交叉乱序;
8
+ * * 有界队列,超出容量时丢弃新消息并告警(丢弃新消息而非旧消息,
9
+ * 因为旧消息已经在途、语义上更接近用户已完成的操作);
10
+ * * 对外暴露 pause / resume,便于断线或重启子进程时挂起消费。
11
+ */
12
+
13
+ class MessageQueue {
14
+ /**
15
+ * @param {object} opts
16
+ * @param {number} [opts.maxSize] 最大排队条数
17
+ * @param {Function} opts.handler 消费函数 async (item, meta) => void
18
+ * @param {object} opts.logger
19
+ */
20
+ constructor(opts) {
21
+ this.maxSize = opts.maxSize ?? 100;
22
+ this.handler = opts.handler;
23
+ this.logger = opts.logger;
24
+
25
+ this._queue = [];
26
+ this._running = false;
27
+ this._paused = false;
28
+ this._idleWaiters = [];
29
+
30
+ this.stats = { enqueued: 0, processed: 0, failed: 0, dropped: 0, maxDepthSeen: 0 };
31
+ }
32
+
33
+ /** 当前排队深度(不含正在处理的那条)。 */
34
+ get depth() {
35
+ return this._queue.length;
36
+ }
37
+
38
+ get busy() {
39
+ return this._running;
40
+ }
41
+
42
+ /**
43
+ * 入队一条消息。
44
+ * @returns {boolean} true=已入队,false=因队列满被丢弃
45
+ */
46
+ push(item, meta = {}) {
47
+ if (this._queue.length >= this.maxSize) {
48
+ this.stats.dropped += 1;
49
+ this.logger?.warn('message queue overflow, dropping new message', {
50
+ maxSize: this.maxSize,
51
+ textLen: typeof item?.text === 'string' ? item.text.length : 0,
52
+ });
53
+ return false;
54
+ }
55
+ this._queue.push({ item, meta });
56
+ this.stats.enqueued += 1;
57
+ if (this._queue.length > this.stats.maxDepthSeen) {
58
+ this.stats.maxDepthSeen = this._queue.length;
59
+ }
60
+ this.logger?.debug('message enqueued', { depth: this._queue.length });
61
+ // 触发消费(不 await,避免调用方被阻塞)
62
+ this._drain();
63
+ return true;
64
+ }
65
+
66
+ /** 暂停消费:已在途的那条会跑完,后续不再取新消息。 */
67
+ pause() {
68
+ this._paused = true;
69
+ this.logger?.debug('queue paused');
70
+ }
71
+
72
+ /** 恢复消费。 */
73
+ resume() {
74
+ if (!this._paused) return;
75
+ this._paused = false;
76
+ this.logger?.debug('queue resumed', { depth: this._queue.length });
77
+ this._drain();
78
+ }
79
+
80
+ /** 清空队列,返回被清掉的条数。 */
81
+ clear(reason = 'cleared') {
82
+ const n = this._queue.length;
83
+ this._queue = [];
84
+ if (n > 0) this.logger?.warn('queue cleared', { count: n, reason });
85
+ return n;
86
+ }
87
+
88
+ /** 等待队列彻底空闲(含正在处理的那条)。 */
89
+ async waitIdle(timeoutMs = 30000) {
90
+ if (!this._running && this._queue.length === 0) return true;
91
+ return new Promise((resolve) => {
92
+ const timer = setTimeout(() => resolve(false), timeoutMs);
93
+ timer.unref?.();
94
+ this._idleWaiters.push(() => {
95
+ clearTimeout(timer);
96
+ resolve(true);
97
+ });
98
+ });
99
+ }
100
+
101
+ _notifyIdle() {
102
+ if (this._running || this._queue.length > 0) return;
103
+ const waiters = this._idleWaiters;
104
+ this._idleWaiters = [];
105
+ for (const w of waiters) w();
106
+ }
107
+
108
+ /** 串行消费主循环。 */
109
+ async _drain() {
110
+ if (this._running) return; // 已有消费者在跑,保证只有一条在途
111
+ if (this._paused) return;
112
+ if (this._queue.length === 0) {
113
+ this._notifyIdle();
114
+ return;
115
+ }
116
+
117
+ this._running = true;
118
+ try {
119
+ while (!this._paused && this._queue.length > 0) {
120
+ const { item, meta } = this._queue.shift();
121
+ try {
122
+ // 顺序 await:这是「串行」的关键
123
+ await this.handler(item, meta);
124
+ this.stats.processed += 1;
125
+ } catch (e) {
126
+ this.stats.failed += 1;
127
+ this.logger?.error('queue handler failed', {
128
+ error: e.message,
129
+ textLen: typeof item?.text === 'string' ? item.text.length : 0,
130
+ });
131
+ // 单条失败不影响后续消息
132
+ }
133
+ }
134
+ } finally {
135
+ this._running = false;
136
+ }
137
+ this._notifyIdle();
138
+ }
139
+ }
140
+
141
+ module.exports = { MessageQueue };
package/lib/sanitize.js CHANGED
@@ -1,71 +1,71 @@
1
- 'use strict';
2
-
3
- /**
4
- * 文本预处理(需求文档 4.2.2 第 3 条)。
5
- *
6
- * 规则:
7
- * 1. 过滤不可见控制字符(换行/Tab 视为可保留的可见分隔,其余控制字符剔除);
8
- * 2. 过滤零宽字符(\u200B-\u200D、\uFEFF 等),它们会造成「看似输入了却看不见」的困惑;
9
- * 3. 空串(含只有空白)直接丢弃;
10
- * 4. 超长文本截断并告警。
11
- */
12
-
13
- /** 默认最大长度(字符数)。按语音输入场景,5000 字符足够,且能规避注入卡死。 */
14
- const DEFAULT_MAX_LENGTH = 5000;
15
-
16
- /**
17
- * 判断是否为应剔除的不可见字符。
18
- * 注意:刻意保留 \n 和 \t —— 语音文本里它们通常是有效分隔。
19
- */
20
- function isInvisible(c) {
21
- const code = c.codePointAt(0);
22
- // 零宽字符与 BOM
23
- if (code === 0x200b || code === 0x200c || code === 0x200d || code === 0xfeff) return true;
24
- // 其他 C0/C1 控制字符(保留 \n \t \r 交给上层处理)
25
- if (c === '\n' || c === '\t' || c === '\r') return false;
26
- if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
27
- return false;
28
- }
29
-
30
- /**
31
- * 预处理文本。
32
- * @param {string} raw
33
- * @param {object} [opts]
34
- * @param {number} [opts.maxLength]
35
- * @returns {{ok:boolean, text:string, reason?:string, truncated?:boolean, originalLength?:number}}
36
- */
37
- function sanitize(raw, opts = {}) {
38
- const maxLength = opts.maxLength ?? DEFAULT_MAX_LENGTH;
39
-
40
- if (typeof raw !== 'string') {
41
- return { ok: false, text: '', reason: 'not_a_string' };
42
- }
43
-
44
- const originalLength = raw.length;
45
-
46
- // 1) 剔除不可见字符
47
- let text = '';
48
- for (const ch of raw) {
49
- if (!isInvisible(ch)) text += ch;
50
- }
51
-
52
- // 2) 统一换行,避免 \r\n 造成目标程序出现多余字符
53
- text = text.replace(/\r\n?/g, '\n');
54
-
55
- // 3) 空文本丢弃
56
- if (text.trim().length === 0) {
57
- return { ok: false, text: '', reason: 'empty_after_clean', originalLength };
58
- }
59
-
60
- // 4) 超长截断
61
- let truncated = false;
62
- const chars = Array.from(text);
63
- if (chars.length > maxLength) {
64
- text = chars.slice(0, maxLength).join('');
65
- truncated = true;
66
- }
67
-
68
- return { ok: true, text, truncated, originalLength };
69
- }
70
-
71
- module.exports = { sanitize, isInvisible, DEFAULT_MAX_LENGTH };
1
+ 'use strict';
2
+
3
+ /**
4
+ * 文本预处理(需求文档 4.2.2 第 3 条)。
5
+ *
6
+ * 规则:
7
+ * 1. 过滤不可见控制字符(换行/Tab 视为可保留的可见分隔,其余控制字符剔除);
8
+ * 2. 过滤零宽字符(\u200B-\u200D、\uFEFF 等),它们会造成「看似输入了却看不见」的困惑;
9
+ * 3. 空串(含只有空白)直接丢弃;
10
+ * 4. 超长文本截断并告警。
11
+ */
12
+
13
+ /** 默认最大长度(字符数)。按语音输入场景,5000 字符足够,且能规避注入卡死。 */
14
+ const DEFAULT_MAX_LENGTH = 5000;
15
+
16
+ /**
17
+ * 判断是否为应剔除的不可见字符。
18
+ * 注意:刻意保留 \n 和 \t —— 语音文本里它们通常是有效分隔。
19
+ */
20
+ function isInvisible(c) {
21
+ const code = c.codePointAt(0);
22
+ // 零宽字符与 BOM
23
+ if (code === 0x200b || code === 0x200c || code === 0x200d || code === 0xfeff) return true;
24
+ // 其他 C0/C1 控制字符(保留 \n \t \r 交给上层处理)
25
+ if (c === '\n' || c === '\t' || c === '\r') return false;
26
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * 预处理文本。
32
+ * @param {string} raw
33
+ * @param {object} [opts]
34
+ * @param {number} [opts.maxLength]
35
+ * @returns {{ok:boolean, text:string, reason?:string, truncated?:boolean, originalLength?:number}}
36
+ */
37
+ function sanitize(raw, opts = {}) {
38
+ const maxLength = opts.maxLength ?? DEFAULT_MAX_LENGTH;
39
+
40
+ if (typeof raw !== 'string') {
41
+ return { ok: false, text: '', reason: 'not_a_string' };
42
+ }
43
+
44
+ const originalLength = raw.length;
45
+
46
+ // 1) 剔除不可见字符
47
+ let text = '';
48
+ for (const ch of raw) {
49
+ if (!isInvisible(ch)) text += ch;
50
+ }
51
+
52
+ // 2) 统一换行,避免 \r\n 造成目标程序出现多余字符
53
+ text = text.replace(/\r\n?/g, '\n');
54
+
55
+ // 3) 空文本丢弃
56
+ if (text.trim().length === 0) {
57
+ return { ok: false, text: '', reason: 'empty_after_clean', originalLength };
58
+ }
59
+
60
+ // 4) 超长截断
61
+ let truncated = false;
62
+ const chars = Array.from(text);
63
+ if (chars.length > maxLength) {
64
+ text = chars.slice(0, maxLength).join('');
65
+ truncated = true;
66
+ }
67
+
68
+ return { ok: true, text, truncated, originalLength };
69
+ }
70
+
71
+ module.exports = { sanitize, isInvisible, DEFAULT_MAX_LENGTH };
package/lib/store.js CHANGED
@@ -1,75 +1,75 @@
1
- 'use strict';
2
-
3
- /**
4
- * 客户端本地转录记录持久化(需求文档 4.2.2 第 5 条)。
5
- * jsonlines,字段:timestamp / text / status / errorMsg
6
- */
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
-
11
- class LocalStore {
12
- constructor({ file, logger }) {
13
- this.file = file;
14
- this.logger = logger;
15
- try {
16
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
17
- if (!fs.existsSync(this.file)) fs.writeFileSync(this.file, '');
18
- } catch (e) {
19
- this.logger?.warn('cannot init local record file', { file, error: e.message });
20
- this.disabled = true;
21
- }
22
- }
23
-
24
- append(entry) {
25
- const record = {
26
- timestamp: new Date().toISOString(),
27
- text: entry.text ?? '',
28
- status: entry.status ?? 'unknown',
29
- errorMsg: entry.errorMsg ?? null,
30
- clientId: entry.clientId ?? null,
31
- queueWaitMs: entry.queueWaitMs ?? null,
32
- durationMs: entry.durationMs ?? null,
33
- truncated: entry.truncated ?? false,
34
- source: entry.source ?? null,
35
- };
36
- if (this.disabled) return record;
37
- try {
38
- fs.appendFileSync(this.file, `${JSON.stringify(record)}\n`, 'utf8');
39
- } catch (e) {
40
- this.logger?.warn('failed to append local record', { error: e.message });
41
- }
42
- return record;
43
- }
44
-
45
- /** 读取最近 N 条,倒序。 */
46
- tail(limit = 50) {
47
- try {
48
- const lines = fs.readFileSync(this.file, 'utf8').split('\n').filter(Boolean);
49
- return lines
50
- .slice(-Math.max(1, limit))
51
- .map((l) => { try { return JSON.parse(l); } catch { return { raw: l }; } })
52
- .reverse();
53
- } catch {
54
- return [];
55
- }
56
- }
57
-
58
- /** 简单统计:成功 / 失败 条数。 */
59
- stats() {
60
- try {
61
- const lines = fs.readFileSync(this.file, 'utf8').split('\n').filter(Boolean);
62
- let ok = 0;
63
- let failed = 0;
64
- for (const l of lines) {
65
- if (l.includes('"status":"injected"')) ok += 1;
66
- else if (l.includes('"status":"inject_failed"') || l.includes('"status":"dropped"')) failed += 1;
67
- }
68
- return { total: lines.length, injected: ok, failed };
69
- } catch {
70
- return { total: 0, injected: 0, failed: 0 };
71
- }
72
- }
73
- }
74
-
75
- module.exports = { LocalStore };
1
+ 'use strict';
2
+
3
+ /**
4
+ * 客户端本地转录记录持久化(需求文档 4.2.2 第 5 条)。
5
+ * jsonlines,字段:timestamp / text / status / errorMsg
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ class LocalStore {
12
+ constructor({ file, logger }) {
13
+ this.file = file;
14
+ this.logger = logger;
15
+ try {
16
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
17
+ if (!fs.existsSync(this.file)) fs.writeFileSync(this.file, '');
18
+ } catch (e) {
19
+ this.logger?.warn('cannot init local record file', { file, error: e.message });
20
+ this.disabled = true;
21
+ }
22
+ }
23
+
24
+ append(entry) {
25
+ const record = {
26
+ timestamp: new Date().toISOString(),
27
+ text: entry.text ?? '',
28
+ status: entry.status ?? 'unknown',
29
+ errorMsg: entry.errorMsg ?? null,
30
+ clientId: entry.clientId ?? null,
31
+ queueWaitMs: entry.queueWaitMs ?? null,
32
+ durationMs: entry.durationMs ?? null,
33
+ truncated: entry.truncated ?? false,
34
+ source: entry.source ?? null,
35
+ };
36
+ if (this.disabled) return record;
37
+ try {
38
+ fs.appendFileSync(this.file, `${JSON.stringify(record)}\n`, 'utf8');
39
+ } catch (e) {
40
+ this.logger?.warn('failed to append local record', { error: e.message });
41
+ }
42
+ return record;
43
+ }
44
+
45
+ /** 读取最近 N 条,倒序。 */
46
+ tail(limit = 50) {
47
+ try {
48
+ const lines = fs.readFileSync(this.file, 'utf8').split('\n').filter(Boolean);
49
+ return lines
50
+ .slice(-Math.max(1, limit))
51
+ .map((l) => { try { return JSON.parse(l); } catch { return { raw: l }; } })
52
+ .reverse();
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /** 简单统计:成功 / 失败 条数。 */
59
+ stats() {
60
+ try {
61
+ const lines = fs.readFileSync(this.file, 'utf8').split('\n').filter(Boolean);
62
+ let ok = 0;
63
+ let failed = 0;
64
+ for (const l of lines) {
65
+ if (l.includes('"status":"injected"')) ok += 1;
66
+ else if (l.includes('"status":"inject_failed"') || l.includes('"status":"dropped"')) failed += 1;
67
+ }
68
+ return { total: lines.length, injected: ok, failed };
69
+ } catch {
70
+ return { total: 0, injected: 0, failed: 0 };
71
+ }
72
+ }
73
+ }
74
+
75
+ module.exports = { LocalStore };