@twinsearth/dsh-master 3.3.4 → 3.3.6
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 +1 -0
- package/client.js +83 -4
- package/cordis.patch.yml +2 -0
- package/dist-host/agents/pjm-agent.js +18 -2
- package/dist-host/agents/pm-agent.js +23 -1
- package/dist-host/agents/prompts.js +16 -6
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +36 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +153 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +90 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +114 -2
- package/dist-host/kernel.js +7 -7
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +230 -6
- package/docs/AGENTS-PM-PJM.md +90 -6
- package/docs/CHANGELOG.md +63 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +3 -1
- package/src/agents/pjm-agent.ts +18 -2
- package/src/agents/pm-agent.ts +21 -1
- package/src/agents/prompts.ts +16 -6
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +35 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +148 -1
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +88 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +105 -2
- package/src/kernel.ts +7 -7
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +211 -6
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 智囊团 WebSocket 通道(宿主侧)。
|
|
6
|
+
*
|
|
7
|
+
* 能力边界(重要):
|
|
8
|
+
* * 可以:握手、鉴权、心跳、推 dual 快照、收到 chat/answer 时返回具名错误;
|
|
9
|
+
* * 不可以:把用户消息提交给 PM/PJM 会话 —— 插件侧没有该 Host API;
|
|
10
|
+
* * 不假装:`chat` 返回 `session-prompt-unavailable`,并把缺失能力写进 hello 的 capabilities。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不用 `ws` 依赖:房主坚持零运行时依赖;`registerUpgrade` 已由宿主提供,
|
|
13
|
+
* 帧编解码在 `ws.ts` 里自己实现(有 RFC 测试向量)。
|
|
14
|
+
*/
|
|
15
|
+
import { createFrameParser, encodeClose, encodePing, encodePong, encodeText, rejectionResponse, upgradeResponse, validateUpgradeRequest } from "./ws.js";
|
|
16
|
+
import { dualMessage, interruptedMessage, parseClientMessage, serverError, serverHello } from "./protocol.js";
|
|
17
|
+
import { createAbortRegistry } from "./abort-registry.js";
|
|
18
|
+
export const CHANNEL_PATH = '/dsh-master/ws/command-center';
|
|
19
|
+
const DEFAULTS = Object.freeze({ heartbeatMs: 30_000, maxConnections: 8 });
|
|
20
|
+
function text(value) { return String(value ?? '').trim(); }
|
|
21
|
+
/** 从升级请求的 URL 里取 token(`?token=`)或 Authorization 头。 */
|
|
22
|
+
export function tokenOf(req) {
|
|
23
|
+
const raw = String(req?.url ?? '');
|
|
24
|
+
const queryIndex = raw.indexOf('?');
|
|
25
|
+
const query = queryIndex >= 0 ? raw.slice(queryIndex + 1) : '';
|
|
26
|
+
for (const pair of query.split('&')) {
|
|
27
|
+
const [key, value = ''] = pair.split('=');
|
|
28
|
+
if (decodeURIComponent(key) === 'token')
|
|
29
|
+
return decodeURIComponent(value);
|
|
30
|
+
}
|
|
31
|
+
const header = String(req?.headers?.authorization ?? '');
|
|
32
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
33
|
+
return match ? match[1].trim() : '';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* @param {{
|
|
37
|
+
* webServer: object,
|
|
38
|
+
* token: string,
|
|
39
|
+
* dualSnapshot: () => object,
|
|
40
|
+
* logger?: { info?: Function, warn?: Function, error?: Function },
|
|
41
|
+
* heartbeatMs?: number,
|
|
42
|
+
* maxConnections?: number,
|
|
43
|
+
* now?: () => number,
|
|
44
|
+
* }} deps
|
|
45
|
+
*/
|
|
46
|
+
export function attachCommandCenterChannel(deps = {}) {
|
|
47
|
+
const webServer = deps.webServer;
|
|
48
|
+
const token = text(deps.token);
|
|
49
|
+
const logger = deps.logger ?? {};
|
|
50
|
+
const now = typeof deps.now === 'function' ? deps.now : () => Date.now();
|
|
51
|
+
const heartbeatMs = Number(deps.heartbeatMs) > 0 ? Number(deps.heartbeatMs) : DEFAULTS.heartbeatMs;
|
|
52
|
+
const maxConnections = Number(deps.maxConnections) > 0 ? Number(deps.maxConnections) : DEFAULTS.maxConnections;
|
|
53
|
+
const log = (level, message) => { try {
|
|
54
|
+
logger[level]?.(message);
|
|
55
|
+
}
|
|
56
|
+
catch { /* ignore */ } };
|
|
57
|
+
if (!webServer || typeof webServer.registerUpgrade !== 'function') {
|
|
58
|
+
return { attached: false, reason: 'web-server-unavailable' };
|
|
59
|
+
}
|
|
60
|
+
if (!token) {
|
|
61
|
+
// fail-closed:没有 token 就不开通道,避免任何本地进程都能订阅看板数据
|
|
62
|
+
return { attached: false, reason: 'channel-token-missing' };
|
|
63
|
+
}
|
|
64
|
+
const connections = new Set();
|
|
65
|
+
let closed = false;
|
|
66
|
+
/** 最近一次成功握手的连接:生产者登记流时默认绑定到它(单面板本地场景)。 */
|
|
67
|
+
let lastConnection = null;
|
|
68
|
+
/** 全局取消登记表(messageId → AbortController);所有权按连接记录,断连时只中止自己的流。 */
|
|
69
|
+
const streams = createAbortRegistry();
|
|
70
|
+
const ownedStreams = new Map();
|
|
71
|
+
const snapshot = () => {
|
|
72
|
+
try {
|
|
73
|
+
return typeof deps.dualSnapshot === 'function' ? deps.dualSnapshot() : null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const send = (connection, message) => {
|
|
80
|
+
if (connection.closed || !connection.socket.writable)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
connection.socket.write(encodeText(JSON.stringify(message)));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
log('warn', `[channel] write failed: ${String(error?.message ?? error)}`);
|
|
87
|
+
closeConnection(connection, 1011, 'write-failed');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** 中止某连接拥有的全部流(断连、卸载都走这里,避免僵尸流继续烧 token)。 */
|
|
91
|
+
const abortOwnedStreams = (connection, reason) => {
|
|
92
|
+
const owned = ownedStreams.get(connection);
|
|
93
|
+
if (!owned || owned.size === 0) {
|
|
94
|
+
ownedStreams.delete(connection);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const aborted = [];
|
|
98
|
+
for (const messageId of owned) {
|
|
99
|
+
if (streams.abort(messageId, reason))
|
|
100
|
+
aborted.push(messageId);
|
|
101
|
+
}
|
|
102
|
+
ownedStreams.delete(connection);
|
|
103
|
+
if (aborted.length > 0)
|
|
104
|
+
log('info', `[channel] aborted ${aborted.length} stream(s) (${reason})`);
|
|
105
|
+
return aborted;
|
|
106
|
+
};
|
|
107
|
+
const closeConnection = (connection, code = 1000, reason = '') => {
|
|
108
|
+
if (connection.closed)
|
|
109
|
+
return;
|
|
110
|
+
connection.closed = true;
|
|
111
|
+
connections.delete(connection);
|
|
112
|
+
abortOwnedStreams(connection, reason || 'connection-closed');
|
|
113
|
+
try {
|
|
114
|
+
connection.socket.write(encodeClose(code, reason));
|
|
115
|
+
}
|
|
116
|
+
catch { /* ignore */ }
|
|
117
|
+
try {
|
|
118
|
+
connection.socket.end();
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
try {
|
|
122
|
+
connection.socket.destroy();
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
};
|
|
126
|
+
const broadcast = (message) => { for (const connection of connections)
|
|
127
|
+
send(connection, message); };
|
|
128
|
+
const handleClientMessage = (connection, raw) => {
|
|
129
|
+
const parsed = parseClientMessage(raw);
|
|
130
|
+
if (!parsed.ok) {
|
|
131
|
+
send(connection, serverError(parsed.code, parsed.error, parsed.types ? { types: parsed.types } : undefined));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const message = parsed.message;
|
|
135
|
+
if (message.type === 'ping') {
|
|
136
|
+
send(connection, { type: 'pong', at: now() });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (message.type === 'resync') {
|
|
140
|
+
send(connection, dualMessage(snapshot()));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (message.type === 'interrupt') {
|
|
144
|
+
const target = message.messageId;
|
|
145
|
+
const owned = ownedStreams.get(connection) ?? new Set();
|
|
146
|
+
let aborted = [];
|
|
147
|
+
if (target) {
|
|
148
|
+
if (streams.abort(target, 'user-interrupt'))
|
|
149
|
+
aborted = [target];
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
for (const messageId of owned) {
|
|
153
|
+
if (streams.abort(messageId, 'user-interrupt'))
|
|
154
|
+
aborted.push(messageId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (aborted.length === 0) {
|
|
158
|
+
// 没有在跑的流:如实说,不假装已中止
|
|
159
|
+
send(connection, serverError('no-active-stream', 'no stream is running on this connection', { messageId: target }));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
send(connection, interruptedMessage({ messageId: target, aborted: true, savedContent: '' }));
|
|
163
|
+
log('info', `[channel] interrupt: aborted ${aborted.length} stream(s)`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (message.type === 'chat') {
|
|
167
|
+
// 防御性兜底(“打断施法”):同一连接上已有在跑的流时,先中止旧的再处理新请求。
|
|
168
|
+
// 即便前端被绕过(多端/直调),也不会出现两路生成并发烧 token。
|
|
169
|
+
const owned = ownedStreams.get(connection);
|
|
170
|
+
if (owned && owned.size > 0) {
|
|
171
|
+
const aborted = [];
|
|
172
|
+
for (const messageId of owned) {
|
|
173
|
+
if (streams.abort(messageId, 'superseded-by-new-chat'))
|
|
174
|
+
aborted.push(messageId);
|
|
175
|
+
}
|
|
176
|
+
if (aborted.length > 0)
|
|
177
|
+
log('warn', `[channel] new chat superseded ${aborted.length} running stream(s)`);
|
|
178
|
+
}
|
|
179
|
+
// 真实缺口,不编造成功。
|
|
180
|
+
send(connection, serverError('session-prompt-unavailable', 'this host has no API to submit a user message into a preset agent session; use a PM/PJM session to chat', { agent: message.agent, protocol: 1 }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (message.type === 'answer') {
|
|
184
|
+
send(connection, serverError('session-answer-unavailable', 'user questions are answered inside their owning session, not through this channel', { callId: message.callId }));
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* 心跳扫描(单一定时器,不是每连接一个):
|
|
189
|
+
* * 超过 2×heartbeat 没收到任何数据(含浏览器自动回的 pong)→ 判定僵尸连接;
|
|
190
|
+
* * 僵尸连接先中止它拥有的所有流,再 destroy socket(不等对端回 close 帧)。
|
|
191
|
+
* 拔网线/休眠/强杀进程都不会发 close 帧,只有这里能把资源收回来。
|
|
192
|
+
*/
|
|
193
|
+
function heartbeatSweep() {
|
|
194
|
+
for (const connection of [...connections]) {
|
|
195
|
+
if (connection.closed)
|
|
196
|
+
continue;
|
|
197
|
+
if (now() - connection.lastSeenAt > heartbeatMs * 2) {
|
|
198
|
+
log('warn', '[channel] zombie connection detected, terminating');
|
|
199
|
+
closeConnection(connection, 1001, 'heartbeat-timeout');
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
connection.socket.write(encodePing());
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
closeConnection(connection, 1011, 'ping-failed');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const heartbeatTimer = setInterval(heartbeatSweep, Math.max(1_000, Math.floor(heartbeatMs / 2)));
|
|
211
|
+
if (typeof heartbeatTimer.unref === 'function')
|
|
212
|
+
heartbeatTimer.unref();
|
|
213
|
+
const accept = (req, socket) => {
|
|
214
|
+
const validation = validateUpgradeRequest(req);
|
|
215
|
+
if (!validation.ok) {
|
|
216
|
+
socket.write(rejectionResponse(400, validation.code, 'invalid websocket handshake'));
|
|
217
|
+
socket.destroy();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Origin 必须与宿主一致(浏览器 WS 一定带 Origin):挡掉跨站页面直连看板。
|
|
221
|
+
const origin = text(req?.headers?.origin);
|
|
222
|
+
const allowedOrigin = text(deps.allowedOrigin);
|
|
223
|
+
if (origin && allowedOrigin && origin !== allowedOrigin) {
|
|
224
|
+
log('warn', `[channel] rejected connection: bad origin ${origin}`);
|
|
225
|
+
socket.write(rejectionResponse(403, 'channel-origin-rejected', 'origin does not match the host'));
|
|
226
|
+
socket.destroy();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (tokenOf(req) !== token) {
|
|
230
|
+
log('warn', '[channel] rejected connection: bad token');
|
|
231
|
+
socket.write(rejectionResponse(401, 'channel-unauthorized', 'missing or invalid channel token'));
|
|
232
|
+
socket.destroy();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (connections.size >= maxConnections) {
|
|
236
|
+
socket.write(rejectionResponse(403, 'channel-full', `at most ${maxConnections} connections`));
|
|
237
|
+
socket.destroy();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
socket.write(upgradeResponse(validation.accept));
|
|
241
|
+
const connection = { socket, closed: false, parser: createFrameParser(), lastSeenAt: now(), pingedAt: null };
|
|
242
|
+
connections.add(connection);
|
|
243
|
+
lastConnection = connection;
|
|
244
|
+
log('info', `[channel] connection accepted (${connections.size}/${maxConnections})`);
|
|
245
|
+
send(connection, serverHello({ tokenRequired: true }));
|
|
246
|
+
send(connection, dualMessage(snapshot()));
|
|
247
|
+
socket.on('data', (chunk) => {
|
|
248
|
+
if (connection.closed)
|
|
249
|
+
return;
|
|
250
|
+
connection.lastSeenAt = now();
|
|
251
|
+
let parsed = null;
|
|
252
|
+
try {
|
|
253
|
+
parsed = connection.parser.push(chunk);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
log('warn', `[channel] frame parse failed: ${String(error?.message ?? error)}`);
|
|
257
|
+
closeConnection(connection, 1002, 'protocol-error');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const error of parsed.errors ?? []) {
|
|
261
|
+
closeConnection(connection, 1002, error);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const control of parsed.controls ?? []) {
|
|
265
|
+
if (control.type === 'ping') {
|
|
266
|
+
try {
|
|
267
|
+
connection.socket.write(encodePong(control.payload));
|
|
268
|
+
}
|
|
269
|
+
catch { /* ignore */ }
|
|
270
|
+
}
|
|
271
|
+
if (control.type === 'close') {
|
|
272
|
+
closeConnection(connection, 1000, 'client-close');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const message of parsed.messages ?? [])
|
|
277
|
+
handleClientMessage(connection, message);
|
|
278
|
+
});
|
|
279
|
+
socket.on('error', (error) => {
|
|
280
|
+
log('warn', `[channel] socket error: ${String(error?.message ?? error)}`);
|
|
281
|
+
closeConnection(connection, 1011, 'socket-error');
|
|
282
|
+
});
|
|
283
|
+
socket.on('close', () => {
|
|
284
|
+
if (connection.heartbeat)
|
|
285
|
+
clearInterval(connection.heartbeat);
|
|
286
|
+
abortOwnedStreams(connection, 'connection-closed');
|
|
287
|
+
connection.closed = true;
|
|
288
|
+
connections.delete(connection);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
let dispose = null;
|
|
292
|
+
try {
|
|
293
|
+
dispose = webServer.registerUpgrade({
|
|
294
|
+
path: CHANNEL_PATH,
|
|
295
|
+
handler: (req, socket) => { try {
|
|
296
|
+
accept(req, socket);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
log('error', `[channel] upgrade failed: ${String(error?.message ?? error)}`);
|
|
300
|
+
try {
|
|
301
|
+
socket.destroy();
|
|
302
|
+
}
|
|
303
|
+
catch { /* ignore */ }
|
|
304
|
+
} },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
return { attached: false, reason: 'register-upgrade-failed', error: String(error?.message ?? error) };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
attached: true,
|
|
312
|
+
path: CHANNEL_PATH,
|
|
313
|
+
connections: () => connections.size,
|
|
314
|
+
/** 主动推一次快照(供 dual 状态变化时调用)。 */
|
|
315
|
+
publish: () => broadcast(dualMessage(snapshot())),
|
|
316
|
+
/**
|
|
317
|
+
* 生产者登记一条可中断的流(会话桥接入后由它调用)。
|
|
318
|
+
* 返回值里的 `signal` 被中止时必须停止推送;`end()` 收尾清理。
|
|
319
|
+
*/
|
|
320
|
+
openStream: (messageId, { connection = lastConnection, onAbort = null } = {}) => {
|
|
321
|
+
const id = text(messageId);
|
|
322
|
+
if (!id)
|
|
323
|
+
throw new Error('openStream: messageId is required');
|
|
324
|
+
const entry = streams.begin(id, { onAbort });
|
|
325
|
+
if (connection) {
|
|
326
|
+
const owned = ownedStreams.get(connection) ?? new Set();
|
|
327
|
+
owned.add(id);
|
|
328
|
+
ownedStreams.set(connection, owned);
|
|
329
|
+
}
|
|
330
|
+
return entry;
|
|
331
|
+
},
|
|
332
|
+
activeStreams: () => streams.keys(),
|
|
333
|
+
/** 手动触发一次心跳扫描(测试与运维用)。 */
|
|
334
|
+
heartbeatTick: heartbeatSweep,
|
|
335
|
+
dispose: () => {
|
|
336
|
+
if (closed)
|
|
337
|
+
return;
|
|
338
|
+
closed = true;
|
|
339
|
+
clearInterval(heartbeatTimer);
|
|
340
|
+
streams.abortAll('server-shutdown');
|
|
341
|
+
for (const connection of [...connections])
|
|
342
|
+
closeConnection(connection, 1001, 'server-shutdown');
|
|
343
|
+
try {
|
|
344
|
+
dispose?.();
|
|
345
|
+
}
|
|
346
|
+
catch { /* ignore */ }
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 流式消息状态机(3.3.5)。
|
|
6
|
+
*
|
|
7
|
+
* 结构(3.3.5 性能版):**历史与当前流式分开**
|
|
8
|
+
* state.history —— 已定稿的消息数组,**引用稳定**:只有消息被定稿时才换新数组;
|
|
9
|
+
* state.active —— 当前正在流式的那条(或 null),每 chunk 只换这一个对象;
|
|
10
|
+
* state.messages —— 兼容视图(history + active),供旧代码/简单渲染使用。
|
|
11
|
+
*
|
|
12
|
+
* 为什么要拆:每 chunk 都让 UI `map` 一遍上百条历史是 O(N) 的 reconciliation 开销,
|
|
13
|
+
* `React.memo` 只能省 DOM,省不了上层遍历。消费方应当:
|
|
14
|
+
* * 历史列表订阅 `history`(引用没变就不重渲染);
|
|
15
|
+
* * 当前气泡只订阅 `active`(每次 chunk 只重渲染它)。
|
|
16
|
+
*
|
|
17
|
+
* 语义:
|
|
18
|
+
* * 幂等:重复 `stream_start` 不新建第二条;重复 `card_choice` 原地更新;
|
|
19
|
+
* * 容忍乱序:`stream_chunk` 早于 `stream_start` 只计数(orphan),不造数据;
|
|
20
|
+
* * 块级锁:`card_*` 到达时先定稿当前气泡,再插入卡片,并释放流式槽位;
|
|
21
|
+
* * 有界:历史条数、单条内容、选项数、快照大小都有上限。
|
|
22
|
+
*/
|
|
23
|
+
export const STREAM_LIMITS = Object.freeze({
|
|
24
|
+
maxMessages: 200,
|
|
25
|
+
maxContentChars: 200_000,
|
|
26
|
+
maxOptions: 20,
|
|
27
|
+
maxSnapshotBytes: 256 * 1024,
|
|
28
|
+
});
|
|
29
|
+
export const MESSAGE_KINDS = Object.freeze(['text', 'choice', 'doc', 'status', 'error']);
|
|
30
|
+
function text(value) { return String(value ?? ''); }
|
|
31
|
+
function emptyState() {
|
|
32
|
+
return {
|
|
33
|
+
messages: [],
|
|
34
|
+
history: [],
|
|
35
|
+
active: null,
|
|
36
|
+
activeStreamId: null,
|
|
37
|
+
capabilities: null,
|
|
38
|
+
snapshot: null,
|
|
39
|
+
orphanChunks: 0,
|
|
40
|
+
ignored: 0,
|
|
41
|
+
truncated: 0,
|
|
42
|
+
lastError: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function capHistory(history) {
|
|
46
|
+
return history.length > STREAM_LIMITS.maxMessages
|
|
47
|
+
? history.slice(history.length - STREAM_LIMITS.maxMessages)
|
|
48
|
+
: history;
|
|
49
|
+
}
|
|
50
|
+
function capContent(content, truncatedBefore) {
|
|
51
|
+
const value = text(content);
|
|
52
|
+
if (value.length <= STREAM_LIMITS.maxContentChars)
|
|
53
|
+
return { content: value, truncated: truncatedBefore };
|
|
54
|
+
return { content: value.slice(0, STREAM_LIMITS.maxContentChars), truncated: truncatedBefore + 1 };
|
|
55
|
+
}
|
|
56
|
+
/** 当前正在流式的那条消息;没有则 null。 */
|
|
57
|
+
export function activeMessage(state) {
|
|
58
|
+
return state?.active ?? null;
|
|
59
|
+
}
|
|
60
|
+
/** 是否有消息仍在流式输出(UI 据此切换“发送/停止”按钮)。 */
|
|
61
|
+
export function isGenerating(state) {
|
|
62
|
+
return Boolean(state?.active && state.active.streaming === true);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 光标微状态(UI 直接消费,不要在组件里重复推导):
|
|
66
|
+
* * `thinking` —— 收到 stream_start 但还没有第一个字:显示跳动点,不显示竖线;
|
|
67
|
+
* * `streaming` —— 正在吐字:竖线紧贴最后一个字符;
|
|
68
|
+
* * `interrupted` —— 最近一条消息是被中止的:红色闪烁两次后收起;
|
|
69
|
+
* * `idle` —— 没有正在生成的内容。
|
|
70
|
+
*/
|
|
71
|
+
export function cursorState(state) {
|
|
72
|
+
if (state?.active && state.active.streaming === true) {
|
|
73
|
+
return text(state.active.content).length === 0 ? 'thinking' : 'streaming';
|
|
74
|
+
}
|
|
75
|
+
const history = Array.isArray(state?.history) ? state.history : [];
|
|
76
|
+
const last = history.length > 0 ? history[history.length - 1] : null;
|
|
77
|
+
if (last && last.interrupted === true)
|
|
78
|
+
return 'interrupted';
|
|
79
|
+
return 'idle';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* @param {{now?: () => number}} [options]
|
|
83
|
+
*/
|
|
84
|
+
export function createChatStream(options = {}) {
|
|
85
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
86
|
+
let state = emptyState();
|
|
87
|
+
const listeners = new Set();
|
|
88
|
+
const notify = () => { for (const listener of [...listeners]) {
|
|
89
|
+
try {
|
|
90
|
+
listener(state);
|
|
91
|
+
}
|
|
92
|
+
catch { /* 订阅者异常不影响状态机 */ }
|
|
93
|
+
} };
|
|
94
|
+
/** 由 history + active 构造完整 state(messages 为兼容视图)。 */
|
|
95
|
+
function bundle(next, history, active) {
|
|
96
|
+
const trimmed = capHistory(history);
|
|
97
|
+
const activeValue = active ?? null;
|
|
98
|
+
const messages = activeValue ? [...trimmed, activeValue] : trimmed;
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
...next,
|
|
102
|
+
history: trimmed,
|
|
103
|
+
active: activeValue,
|
|
104
|
+
activeStreamId: activeValue ? activeValue.id : null,
|
|
105
|
+
messages,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** 把当前 active 定稿进 history(不改变 active 对象的 streaming 标记,调用方负责)。 */
|
|
109
|
+
function finalized(next, item) {
|
|
110
|
+
const active = state.active;
|
|
111
|
+
const history = active ? [...state.history, { ...active, ...(item ?? {}) }] : state.history;
|
|
112
|
+
return bundle(next, history, null);
|
|
113
|
+
}
|
|
114
|
+
function applyOne(message) {
|
|
115
|
+
const type = text(message?.type);
|
|
116
|
+
const id = text(message?.messageId ?? message?.id);
|
|
117
|
+
switch (type) {
|
|
118
|
+
case 'stream_start': {
|
|
119
|
+
if (!id)
|
|
120
|
+
return state;
|
|
121
|
+
const existingHistory = state.history.find((entry) => entry.id === id);
|
|
122
|
+
if (existingHistory)
|
|
123
|
+
return state; // 已定稿的同 id 消息不再复活
|
|
124
|
+
if (state.active && state.active.id === id) {
|
|
125
|
+
// 幂等:重复 start 保持同一条
|
|
126
|
+
return bundle({}, state.history, { ...state.active, streaming: true });
|
|
127
|
+
}
|
|
128
|
+
const role = text(message.role) === 'pjm' ? 'pjm' : 'pm';
|
|
129
|
+
const entry = { id, role, kind: 'text', content: '', streaming: true, at: now() };
|
|
130
|
+
const history = state.active ? [...state.history, { ...state.active, streaming: false }] : state.history;
|
|
131
|
+
return bundle({}, history, entry);
|
|
132
|
+
}
|
|
133
|
+
case 'stream_chunk': {
|
|
134
|
+
if (!id)
|
|
135
|
+
return state;
|
|
136
|
+
if (!state.active || state.active.id !== id) {
|
|
137
|
+
return { ...state, orphanChunks: state.orphanChunks + 1 };
|
|
138
|
+
}
|
|
139
|
+
const content = `${text(state.active.content)}${text(message.delta)}`;
|
|
140
|
+
const capped = capContent(content, state.truncated);
|
|
141
|
+
return bundle({ truncated: capped.truncated }, state.history, { ...state.active, content: capped.content, truncated: capped.content.length >= STREAM_LIMITS.maxContentChars });
|
|
142
|
+
}
|
|
143
|
+
case 'stream_end': {
|
|
144
|
+
if (!id)
|
|
145
|
+
return state;
|
|
146
|
+
if (!state.active || state.active.id !== id)
|
|
147
|
+
return { ...state, orphanChunks: state.orphanChunks + 1 };
|
|
148
|
+
const full = text(message.fullContent);
|
|
149
|
+
const current = state.active;
|
|
150
|
+
const finalContent = full.length > text(current.content).length
|
|
151
|
+
? full.slice(0, STREAM_LIMITS.maxContentChars)
|
|
152
|
+
: current.content;
|
|
153
|
+
const entry = {
|
|
154
|
+
...current,
|
|
155
|
+
streaming: false,
|
|
156
|
+
interrupted: message.interrupted === true ? true : current.interrupted === true,
|
|
157
|
+
content: finalContent,
|
|
158
|
+
};
|
|
159
|
+
return bundle({}, [...state.history, entry], null);
|
|
160
|
+
}
|
|
161
|
+
case 'interrupted': {
|
|
162
|
+
if (!id)
|
|
163
|
+
return state;
|
|
164
|
+
if (!state.active || state.active.id !== id)
|
|
165
|
+
return { ...state, orphanChunks: state.orphanChunks + 1 };
|
|
166
|
+
const saved = text(message.savedContent);
|
|
167
|
+
const current = state.active;
|
|
168
|
+
const entry = {
|
|
169
|
+
...current,
|
|
170
|
+
streaming: false,
|
|
171
|
+
interrupted: message.aborted !== false,
|
|
172
|
+
content: saved.length > text(current.content).length ? saved : current.content,
|
|
173
|
+
};
|
|
174
|
+
return bundle({}, [...state.history, entry], null);
|
|
175
|
+
}
|
|
176
|
+
case 'card_choice': {
|
|
177
|
+
if (!id)
|
|
178
|
+
return state;
|
|
179
|
+
const existing = state.history.findIndex((entry) => entry.id === id && entry.kind === 'choice');
|
|
180
|
+
const options = Array.isArray(message.options)
|
|
181
|
+
? message.options.slice(0, STREAM_LIMITS.maxOptions).map((option) => (typeof option === 'string' ? { label: option, value: option } : { label: text(option?.label), value: text(option?.value ?? option?.label) }))
|
|
182
|
+
: [];
|
|
183
|
+
const card = {
|
|
184
|
+
id,
|
|
185
|
+
role: 'pm',
|
|
186
|
+
kind: 'choice',
|
|
187
|
+
content: text(message.question),
|
|
188
|
+
options,
|
|
189
|
+
answered: false,
|
|
190
|
+
answer: null,
|
|
191
|
+
streaming: false,
|
|
192
|
+
at: now(),
|
|
193
|
+
};
|
|
194
|
+
if (existing >= 0) {
|
|
195
|
+
const history = state.history.slice();
|
|
196
|
+
history[existing] = { ...history[existing], ...card };
|
|
197
|
+
return bundle({}, history, state.active);
|
|
198
|
+
}
|
|
199
|
+
const history = state.active
|
|
200
|
+
? [...state.history, { ...state.active, streaming: false }, card]
|
|
201
|
+
: [...state.history, card];
|
|
202
|
+
return bundle({}, history, null);
|
|
203
|
+
}
|
|
204
|
+
case 'card_prd': {
|
|
205
|
+
if (!id)
|
|
206
|
+
return state;
|
|
207
|
+
const doc = {
|
|
208
|
+
id,
|
|
209
|
+
role: 'pm',
|
|
210
|
+
kind: 'doc',
|
|
211
|
+
content: text(message.title),
|
|
212
|
+
summary: text(message.summary ?? message.content ?? ''),
|
|
213
|
+
status: ['draft', 'approved', 'shipped'].includes(text(message.status)) ? text(message.status) : 'draft',
|
|
214
|
+
streaming: false,
|
|
215
|
+
at: now(),
|
|
216
|
+
};
|
|
217
|
+
const history = state.active
|
|
218
|
+
? [...state.history, { ...state.active, streaming: false }, doc]
|
|
219
|
+
: [...state.history, doc];
|
|
220
|
+
return bundle({}, history, null);
|
|
221
|
+
}
|
|
222
|
+
case 'card_status': {
|
|
223
|
+
if (!id)
|
|
224
|
+
return state;
|
|
225
|
+
const progress = Math.max(0, Math.min(100, Number(message.progress) || 0));
|
|
226
|
+
const entry = {
|
|
227
|
+
id,
|
|
228
|
+
role: 'pjm',
|
|
229
|
+
kind: 'status',
|
|
230
|
+
content: text(message.content),
|
|
231
|
+
progress,
|
|
232
|
+
statusText: text(message.statusText),
|
|
233
|
+
streaming: false,
|
|
234
|
+
at: now(),
|
|
235
|
+
};
|
|
236
|
+
const existing = state.history.findIndex((item) => item.id === id && item.kind === 'status');
|
|
237
|
+
if (existing >= 0) {
|
|
238
|
+
const history = state.history.slice();
|
|
239
|
+
history[existing] = { ...history[existing], ...entry };
|
|
240
|
+
return bundle({}, history, state.active);
|
|
241
|
+
}
|
|
242
|
+
const history = state.active
|
|
243
|
+
? [...state.history, { ...state.active, streaming: false }, entry]
|
|
244
|
+
: [...state.history, entry];
|
|
245
|
+
return bundle({}, history, null);
|
|
246
|
+
}
|
|
247
|
+
case 'error': {
|
|
248
|
+
const entry = {
|
|
249
|
+
id: id || `error-${now()}`,
|
|
250
|
+
role: 'system',
|
|
251
|
+
kind: 'error',
|
|
252
|
+
content: text(message.message),
|
|
253
|
+
code: text(message.code),
|
|
254
|
+
streaming: false,
|
|
255
|
+
at: now(),
|
|
256
|
+
};
|
|
257
|
+
const history = state.active
|
|
258
|
+
? [...state.history, { ...state.active, streaming: false }, entry]
|
|
259
|
+
: [...state.history, entry];
|
|
260
|
+
return bundle({ lastError: entry }, history, null);
|
|
261
|
+
}
|
|
262
|
+
case 'dual': {
|
|
263
|
+
const snapshot = message.snapshot ?? null;
|
|
264
|
+
let safeSnapshot = snapshot;
|
|
265
|
+
try {
|
|
266
|
+
if (snapshot && JSON.stringify(snapshot).length > STREAM_LIMITS.maxSnapshotBytes)
|
|
267
|
+
safeSnapshot = null;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
safeSnapshot = null;
|
|
271
|
+
}
|
|
272
|
+
return { ...state, snapshot: safeSnapshot };
|
|
273
|
+
}
|
|
274
|
+
case 'hello': {
|
|
275
|
+
return {
|
|
276
|
+
...state,
|
|
277
|
+
capabilities: {
|
|
278
|
+
protocol: Number(message.protocol) || 0,
|
|
279
|
+
dualSnapshot: message?.capabilities?.dualSnapshot === true,
|
|
280
|
+
chat: message?.capabilities?.chat === true,
|
|
281
|
+
answer: message?.capabilities?.answer === true,
|
|
282
|
+
limitations: message?.limitations ?? null,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
case 'pong':
|
|
287
|
+
return state;
|
|
288
|
+
default:
|
|
289
|
+
return { ...state, ignored: state.ignored + 1 };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
getState: () => state,
|
|
294
|
+
apply: (message) => { state = applyOne(message); notify(); return state; },
|
|
295
|
+
/**
|
|
296
|
+
* 用户回答选择题(本地乐观更新):
|
|
297
|
+
* * 只把那张卡片改成 answered 并写入 answer;
|
|
298
|
+
* * 可选追加一条本地用户气泡(`local: true`);
|
|
299
|
+
* * history 换新数组,其余消息对象引用保持不变。
|
|
300
|
+
* * 重复回答同一张卡片会被拒绝。
|
|
301
|
+
*/
|
|
302
|
+
answerChoice: (messageId, value, options = {}) => {
|
|
303
|
+
const id = text(messageId);
|
|
304
|
+
const answer = text(value);
|
|
305
|
+
if (!id || !answer)
|
|
306
|
+
return { ok: false, code: 'answer-incomplete', error: 'answerChoice requires messageId and value' };
|
|
307
|
+
const index = state.history.findIndex((entry) => entry.id === id);
|
|
308
|
+
if (index < 0)
|
|
309
|
+
return { ok: false, code: 'choice-not-found', error: `no message with id ${id}` };
|
|
310
|
+
const current = state.history[index];
|
|
311
|
+
if (current.kind !== 'choice')
|
|
312
|
+
return { ok: false, code: 'not-a-choice', error: `${id} is not a choice card` };
|
|
313
|
+
if (current.answered === true)
|
|
314
|
+
return { ok: false, code: 'choice-already-answered', error: `choice ${id} was already answered` };
|
|
315
|
+
const history = state.history.slice();
|
|
316
|
+
history[index] = { ...current, answered: true, answer };
|
|
317
|
+
const echo = text(options.echo);
|
|
318
|
+
if (echo) {
|
|
319
|
+
history.push({ id: `local-${now()}`, role: 'user', kind: 'text', content: echo, local: true, streaming: false, at: now() });
|
|
320
|
+
}
|
|
321
|
+
state = bundle({}, history, state.active);
|
|
322
|
+
notify();
|
|
323
|
+
return { ok: true, state };
|
|
324
|
+
},
|
|
325
|
+
/** 批量应用,只通知一次(UI 可按帧 flush)。 */
|
|
326
|
+
applyBatch: (messages) => {
|
|
327
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
328
|
+
for (const message of list)
|
|
329
|
+
state = applyOne(message);
|
|
330
|
+
if (list.length > 0)
|
|
331
|
+
notify();
|
|
332
|
+
return state;
|
|
333
|
+
},
|
|
334
|
+
/** 外部 store 订阅(配合 useSyncExternalStore)。返回取消订阅函数。 */
|
|
335
|
+
subscribe: (listener) => {
|
|
336
|
+
if (typeof listener !== 'function')
|
|
337
|
+
return () => { };
|
|
338
|
+
listeners.add(listener);
|
|
339
|
+
return () => listeners.delete(listener);
|
|
340
|
+
},
|
|
341
|
+
reset: () => { state = emptyState(); notify(); return state; },
|
|
342
|
+
};
|
|
343
|
+
}
|