@xcanwin/manyoyo 8.6.2 → 8.6.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/lib/app-update.js +29 -27
- package/lib/core/event-store.js +23 -1
- package/lib/init-config.js +16 -2
- package/lib/web/AGENTS.md +14 -3
- package/lib/web/container-manage.js +4 -2
- package/lib/web/history-store.js +221 -0
- package/lib/web/index.html +89 -89
- package/lib/web/messages-view.js +182 -0
- package/lib/web/server.js +556 -80
- package/package.json +1 -1
package/lib/web/server.js
CHANGED
|
@@ -23,7 +23,7 @@ const { isBrowserBackground } = require('../browser-background');
|
|
|
23
23
|
const { createDeniedNotifier } = require('./denied-notify');
|
|
24
24
|
const { domainRuleAllows } = require('../network-policy');
|
|
25
25
|
const { createPortForwarder } = require('../port-forward');
|
|
26
|
-
const { createContainerManageRoutes, pendingRisks } = require('./container-manage');
|
|
26
|
+
const { createContainerManageRoutes, pendingRisks, STALE_PAGE_MESSAGE } = require('./container-manage');
|
|
27
27
|
const { resolveManageOptions } = require('../container-manage-options');
|
|
28
28
|
const { defaultPolicy, usesProxy } = require('../network-policy');
|
|
29
29
|
const { normalizeMirrors } = require('../runtime-normalizers');
|
|
@@ -56,6 +56,9 @@ const {
|
|
|
56
56
|
projectSessionEvents
|
|
57
57
|
} = require('../core/events');
|
|
58
58
|
const { FileEventStore } = require('../core/event-store');
|
|
59
|
+
const historyStore = require('./history-store');
|
|
60
|
+
const messagesView = require('./messages-view');
|
|
61
|
+
const zlib = require('zlib');
|
|
59
62
|
const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path');
|
|
60
63
|
const { isValidLogDateTag, readServeLogEntries } = require('../serve-log-reader');
|
|
61
64
|
|
|
@@ -68,6 +71,14 @@ const WEB_TERMINAL_DEFAULT_COLS = 120;
|
|
|
68
71
|
const WEB_TERMINAL_DEFAULT_ROWS = 36;
|
|
69
72
|
const WEB_TERMINAL_MIN_COLS = 40;
|
|
70
73
|
const WEB_TERMINAL_MIN_ROWS = 12;
|
|
74
|
+
// resize 的下限比建连时的 40×12 低:手机竖屏的真实列数可以小于 40,夹到 40 容器就会比网页宽、行尾被切
|
|
75
|
+
const WEB_TERMINAL_RESIZE_MIN_COLS = 10;
|
|
76
|
+
const WEB_TERMINAL_RESIZE_MIN_ROWS = 4;
|
|
77
|
+
const WEB_TERMINAL_MAX_COLS = 500;
|
|
78
|
+
const WEB_TERMINAL_MAX_ROWS = 200;
|
|
79
|
+
// resize 节流:窗口拖动时前端虽有防抖,服务端仍保证两次容器内 stty 之间至少隔这么久
|
|
80
|
+
const WEB_TERMINAL_RESIZE_MIN_INTERVAL_MS = 150;
|
|
81
|
+
const WEB_TERMINAL_ID_PATTERN = /^[0-9a-f]{16}$/;
|
|
71
82
|
// 终端 WebSocket 空闲心跳:纯交互式终端可能长时间没有任何数据,
|
|
72
83
|
// 反向代理 / 移动网络的空闲超时(常见 60s)会悄悄掐断连接,这里定期发 ping
|
|
73
84
|
// 帧制造流量。判死放宽到连续 3 次没回 pong(约 90s),手机切后台导致连接被
|
|
@@ -197,6 +208,8 @@ function createEmptyWebAgentSession(agentId, agentName) {
|
|
|
197
208
|
createdAt: null,
|
|
198
209
|
updatedAt: null,
|
|
199
210
|
messages: [],
|
|
211
|
+
// 稳定前缀被改动时(删除非 pending 消息、克隆)必须换新值,增量同步据此让前端整页重置
|
|
212
|
+
epoch: messagesView.createEpoch(),
|
|
200
213
|
events: [],
|
|
201
214
|
lastResumeAt: null,
|
|
202
215
|
lastResumeOk: null,
|
|
@@ -237,6 +250,9 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
237
250
|
createdAt: typeof source.createdAt === 'string' ? source.createdAt : null,
|
|
238
251
|
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : null,
|
|
239
252
|
messages: Array.isArray(source.messages) ? source.messages : [],
|
|
253
|
+
epoch: typeof source.epoch === 'string' && /^[0-9a-f]{8,32}$/.test(source.epoch)
|
|
254
|
+
? source.epoch
|
|
255
|
+
: messagesView.createEpoch(),
|
|
240
256
|
events: Array.isArray(source.events)
|
|
241
257
|
? source.events.filter(event => {
|
|
242
258
|
try {
|
|
@@ -342,40 +358,40 @@ function normalizeWebHistoryRecord(containerName, rawData) {
|
|
|
342
358
|
function loadWebSessionHistory(webHistoryDir, containerName) {
|
|
343
359
|
ensureWebHistoryDir(webHistoryDir);
|
|
344
360
|
const filePath = getWebHistoryFile(webHistoryDir, containerName);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
361
|
+
const cached = historyStore.read(filePath, raw => normalizeWebHistoryRecord(containerName, raw));
|
|
362
|
+
return cached ? cached.value : normalizeWebHistoryRecord(containerName, {});
|
|
363
|
+
}
|
|
348
364
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
return normalizeWebHistoryRecord(containerName, {});
|
|
353
|
-
}
|
|
365
|
+
// 同一历史的内存版本号,/messages 的 ETag 用它(不用 mtime:同一毫秒内的两次写入会撞)
|
|
366
|
+
function getWebSessionHistoryVersion(webHistoryDir, containerName) {
|
|
367
|
+
return historyStore.getVersion(getWebHistoryFile(webHistoryDir, containerName));
|
|
354
368
|
}
|
|
355
369
|
|
|
356
|
-
function
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const runtimeMeta = getAgentRuntimeMeta(normalized.agentPromptCommand || '');
|
|
361
|
-
const defaultAgent = getWebAgentSession(normalized, WEB_DEFAULT_AGENT_ID);
|
|
362
|
-
const legacyCompatible = {
|
|
363
|
-
...normalized,
|
|
370
|
+
function serializeWebHistory(history) {
|
|
371
|
+
const runtimeMeta = getAgentRuntimeMeta(history.agentPromptCommand || '');
|
|
372
|
+
return JSON.stringify({
|
|
373
|
+
...history,
|
|
364
374
|
agentProgram: runtimeMeta.agentProgram || '',
|
|
365
375
|
resumeSupported: runtimeMeta.resumeSupported === true
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// defer: 只改内存缓存,最迟 1 秒后落盘(流式过程中的 trace 快照用);不传则立即原子写
|
|
380
|
+
function saveWebSessionHistory(webHistoryDir, containerName, history, options = {}) {
|
|
381
|
+
ensureWebHistoryDir(webHistoryDir);
|
|
382
|
+
const filePath = getWebHistoryFile(webHistoryDir, containerName);
|
|
383
|
+
const value = options.defer === true ? history : normalizeWebHistoryRecord(containerName, history);
|
|
384
|
+
historyStore.write(filePath, value, serializeWebHistory, { defer: options.defer === true });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function flushWebSessionHistories() {
|
|
388
|
+
historyStore.flushAll();
|
|
374
389
|
}
|
|
375
390
|
|
|
376
391
|
function removeWebSessionHistory(webHistoryDir, containerName) {
|
|
377
392
|
ensureWebHistoryDir(webHistoryDir);
|
|
378
393
|
const filePath = getWebHistoryFile(webHistoryDir, containerName);
|
|
394
|
+
historyStore.remove(filePath);
|
|
379
395
|
if (fs.existsSync(filePath)) {
|
|
380
396
|
fs.unlinkSync(filePath);
|
|
381
397
|
}
|
|
@@ -399,7 +415,9 @@ function removeContainerIdempotent(ctx, containerName) {
|
|
|
399
415
|
function removeAllAgentHistoryArtifacts(webHistoryDir, containerName, agentIds) {
|
|
400
416
|
const eventStore = new FileEventStore(webHistoryDir);
|
|
401
417
|
(Array.isArray(agentIds) ? agentIds : []).forEach(agentId => {
|
|
402
|
-
|
|
418
|
+
const aggregateId = buildWebSessionKey(containerName, agentId);
|
|
419
|
+
controlEventSeqCache.delete(`${path.resolve(webHistoryDir)}|${aggregateId}`);
|
|
420
|
+
eventStore.remove(aggregateId);
|
|
403
421
|
});
|
|
404
422
|
}
|
|
405
423
|
|
|
@@ -557,23 +575,56 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
|
|
|
557
575
|
return message;
|
|
558
576
|
}
|
|
559
577
|
|
|
560
|
-
|
|
578
|
+
// 事件日志(FileEventStore)每条事件立即追加,而历史快照里的 events 是合并写盘的(强杀后可能落后)。
|
|
579
|
+
// seq 必须以日志为准:进程内记住每个 aggregate 最后追加的 seq,第一次用时从日志读一次。
|
|
580
|
+
const controlEventSeqCache = new Map();
|
|
581
|
+
|
|
582
|
+
function resolveNextControlSeq(eventStore, cacheKey, aggregateId, snapshotEvents, refresh) {
|
|
583
|
+
let last = refresh ? undefined : controlEventSeqCache.get(cacheKey);
|
|
584
|
+
if (last === undefined) {
|
|
585
|
+
const logged = eventStore.read(aggregateId);
|
|
586
|
+
if (logged.length) {
|
|
587
|
+
last = logged[logged.length - 1].seq;
|
|
588
|
+
} else {
|
|
589
|
+
// 日志为空:只有旧版本留下的历史快照里有事件,沿用它的 seq
|
|
590
|
+
const lastSnapshot = snapshotEvents.length ? snapshotEvents[snapshotEvents.length - 1] : null;
|
|
591
|
+
last = lastSnapshot ? lastSnapshot.seq : 0;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return last + 1;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}, options = {}) {
|
|
561
598
|
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
562
599
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
563
600
|
const events = Array.isArray(agentSession.events) ? agentSession.events : [];
|
|
564
|
-
const
|
|
565
|
-
const
|
|
566
|
-
type,
|
|
567
|
-
aggregateId: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
568
|
-
seq: lastEvent ? lastEvent.seq + 1 : 1,
|
|
569
|
-
data
|
|
570
|
-
});
|
|
601
|
+
const aggregateId = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
602
|
+
const cacheKey = `${path.resolve(webHistoryDir)}|${aggregateId}`;
|
|
571
603
|
const eventStore = new FileEventStore(webHistoryDir);
|
|
572
|
-
|
|
604
|
+
let event = null;
|
|
605
|
+
for (let attempt = 0; attempt < 2 && !event; attempt += 1) {
|
|
606
|
+
const candidate = createControlEvent({
|
|
607
|
+
type,
|
|
608
|
+
aggregateId,
|
|
609
|
+
seq: resolveNextControlSeq(eventStore, cacheKey, aggregateId, events, attempt > 0),
|
|
610
|
+
data
|
|
611
|
+
});
|
|
612
|
+
try {
|
|
613
|
+
eventStore.append(candidate);
|
|
614
|
+
event = candidate;
|
|
615
|
+
} catch (error) {
|
|
616
|
+
// 缓存的 seq 与日志对不上(日志被别处改过):丢掉缓存按日志重算一次
|
|
617
|
+
controlEventSeqCache.delete(cacheKey);
|
|
618
|
+
if (attempt > 0) {
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
controlEventSeqCache.set(cacheKey, event.seq);
|
|
573
624
|
agentSession.events = [...events, event].slice(-WEB_HISTORY_MAX_MESSAGES);
|
|
574
625
|
agentSession.updatedAt = event.timestamp;
|
|
575
626
|
history.updatedAt = event.timestamp;
|
|
576
|
-
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
627
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history, { defer: options.defer === true });
|
|
577
628
|
return event;
|
|
578
629
|
}
|
|
579
630
|
|
|
@@ -591,7 +642,7 @@ function loadWebSessionControlEvents(webHistoryDir, sessionRef, fallbackEvents =
|
|
|
591
642
|
return Array.isArray(fallbackEvents) ? fallbackEvents : [];
|
|
592
643
|
}
|
|
593
644
|
|
|
594
|
-
function patchWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messageId, patch) {
|
|
645
|
+
function patchWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messageId, patch, options = {}) {
|
|
595
646
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
596
647
|
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
597
648
|
: sessionRefOrContainerName;
|
|
@@ -615,7 +666,7 @@ function patchWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messag
|
|
|
615
666
|
const timestamp = new Date().toISOString();
|
|
616
667
|
agentSession.updatedAt = timestamp;
|
|
617
668
|
history.updatedAt = timestamp;
|
|
618
|
-
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
669
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history, { defer: options.defer === true });
|
|
619
670
|
return message;
|
|
620
671
|
}
|
|
621
672
|
|
|
@@ -634,6 +685,9 @@ function removeWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messa
|
|
|
634
685
|
return null;
|
|
635
686
|
}
|
|
636
687
|
const removed = agentSession.messages.splice(index, 1)[0] || null;
|
|
688
|
+
if (removed && removed.pending !== true) {
|
|
689
|
+
agentSession.epoch = messagesView.createEpoch();
|
|
690
|
+
}
|
|
637
691
|
const timestamp = new Date().toISOString();
|
|
638
692
|
agentSession.updatedAt = timestamp;
|
|
639
693
|
history.updatedAt = timestamp;
|
|
@@ -2460,6 +2514,24 @@ function redactConfigObject(value) {
|
|
|
2460
2514
|
return result;
|
|
2461
2515
|
}
|
|
2462
2516
|
|
|
2517
|
+
function computeContentEtag(text) {
|
|
2518
|
+
return crypto.createHash('sha256').update(String(text == null ? '' : text)).digest('hex');
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
// 规范化 If-Match:去掉引号和 W/ 前缀、统一小写;* 表示“只要存在就匹配”(原样保留)
|
|
2522
|
+
function normalizeIfMatchHeader(value) {
|
|
2523
|
+
const text = String(value).trim();
|
|
2524
|
+
if (text === '*') {
|
|
2525
|
+
return '*';
|
|
2526
|
+
}
|
|
2527
|
+
return text.replace(/^W\//, '').replace(/"/g, '').trim().toLowerCase();
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function ifMatchSatisfied(headerValue, currentEtag) {
|
|
2531
|
+
const normalized = normalizeIfMatchHeader(headerValue);
|
|
2532
|
+
return normalized === '*' || normalized === String(currentEtag).toLowerCase();
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2463
2535
|
function buildSafeWebConfigSnapshot(snapshot, ctx) {
|
|
2464
2536
|
const parsed = snapshot && snapshot.parseError ? {} : toPlainObject(snapshot && snapshot.parsed);
|
|
2465
2537
|
let raw = '';
|
|
@@ -2480,6 +2552,8 @@ function buildSafeWebConfigSnapshot(snapshot, ctx) {
|
|
|
2480
2552
|
return {
|
|
2481
2553
|
path: snapshot && snapshot.path ? snapshot.path : path.resolve(getDefaultWebConfigPath()),
|
|
2482
2554
|
raw,
|
|
2555
|
+
// 版本号 = 磁盘原文的 sha256(不是脱敏后的文本:只有密钥变了也要能识别),只返回哈希,不泄露内容
|
|
2556
|
+
etag: computeContentEtag(snapshot && typeof snapshot.raw === 'string' ? snapshot.raw : ''),
|
|
2483
2557
|
parsed: redactConfigObject(parsed),
|
|
2484
2558
|
defaults: redactConfigObject(buildConfigDefaults(ctx, parsed)),
|
|
2485
2559
|
parseError: snapshot && snapshot.parseError ? snapshot.parseError : null,
|
|
@@ -3121,6 +3195,7 @@ function cloneWebAgentSessionsDeep(sourceHistory) {
|
|
|
3121
3195
|
: {};
|
|
3122
3196
|
Object.keys(agents).forEach(agentId => {
|
|
3123
3197
|
cloned[agentId] = JSON.parse(JSON.stringify(agents[agentId]));
|
|
3198
|
+
cloned[agentId].epoch = messagesView.createEpoch();
|
|
3124
3199
|
});
|
|
3125
3200
|
return cloned;
|
|
3126
3201
|
}
|
|
@@ -3837,8 +3912,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
|
|
|
3837
3912
|
const fs = require('fs');
|
|
3838
3913
|
const path = require('path');
|
|
3839
3914
|
|
|
3915
|
+
const crypto = require('crypto');
|
|
3916
|
+
|
|
3840
3917
|
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
3841
3918
|
const maxBytes = ${String(maxBytes)};
|
|
3919
|
+
const editMaxBytes = ${String(WEB_FILE_EDIT_MAX_BYTES)};
|
|
3842
3920
|
const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
|
|
3843
3921
|
|
|
3844
3922
|
function looksBinary(buffer) {
|
|
@@ -3889,11 +3967,18 @@ try {
|
|
|
3889
3967
|
truncated: maxBytes > 0 && size > maxBytes
|
|
3890
3968
|
}));
|
|
3891
3969
|
} else {
|
|
3970
|
+
// 版本号:整个文件内容的 sha256(保存时带回来,容器里的文件被改过就能发现);只给可编辑的小文件算
|
|
3971
|
+
let etag = '';
|
|
3972
|
+
if (size < editMaxBytes) {
|
|
3973
|
+
const whole = readBytes === size ? buffer : fs.readFileSync(realPath);
|
|
3974
|
+
etag = crypto.createHash('sha256').update(whole).digest('hex');
|
|
3975
|
+
}
|
|
3892
3976
|
process.stdout.write(JSON.stringify({
|
|
3893
3977
|
path: realPath,
|
|
3894
3978
|
kind: 'text',
|
|
3895
3979
|
size,
|
|
3896
3980
|
truncated: maxBytes > 0 && size > maxBytes,
|
|
3981
|
+
etag,
|
|
3897
3982
|
content: buffer.toString('utf8')
|
|
3898
3983
|
}));
|
|
3899
3984
|
}
|
|
@@ -3931,12 +4016,17 @@ try {
|
|
|
3931
4016
|
`);
|
|
3932
4017
|
}
|
|
3933
4018
|
|
|
3934
|
-
|
|
4019
|
+
// expectedEtag:读取时拿到的内容 sha256。写入前在容器里重新计算当前内容的哈希,不一致就不写(返回 conflict)。
|
|
4020
|
+
// 检查和写入之间仍有一个极短的窗口(同一个脚本里的两条语句之间),可以接受。
|
|
4021
|
+
function buildContainerFileWriteCommand(requestedPath, content, expectedEtag) {
|
|
3935
4022
|
return buildWebContainerNodeCommand(`
|
|
3936
4023
|
// __MANYOYO_FS_WRITE__
|
|
3937
4024
|
const fs = require('fs');
|
|
4025
|
+
const crypto = require('crypto');
|
|
3938
4026
|
|
|
3939
4027
|
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
4028
|
+
const editMaxBytes = ${String(WEB_FILE_EDIT_MAX_BYTES)};
|
|
4029
|
+
const expectedEtag = ${JSON.stringify(String(expectedEtag == null ? '' : expectedEtag))};
|
|
3940
4030
|
const nextContent = ${JSON.stringify(String(content == null ? '' : content))};
|
|
3941
4031
|
|
|
3942
4032
|
try {
|
|
@@ -3946,13 +4036,26 @@ try {
|
|
|
3946
4036
|
throw new Error('目标不是文件: ' + realPath);
|
|
3947
4037
|
}
|
|
3948
4038
|
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
4039
|
+
// 太大的文件不整个读进内存比对:前端本来就不允许编辑,直接给明确的提示(而不是让用户看到“冲突”)
|
|
4040
|
+
if (stat.size >= editMaxBytes) {
|
|
4041
|
+
throw new Error('文件过大,当前仅支持编辑小于 2MB 的文本文件');
|
|
4042
|
+
}
|
|
4043
|
+
const currentEtag = crypto.createHash('sha256').update(fs.readFileSync(realPath)).digest('hex');
|
|
4044
|
+
if (expectedEtag !== '*' && currentEtag !== expectedEtag) {
|
|
4045
|
+
process.stdout.write(JSON.stringify({
|
|
4046
|
+
conflict: true,
|
|
4047
|
+
error: '文件已在容器里被修改'
|
|
4048
|
+
}));
|
|
4049
|
+
} else {
|
|
4050
|
+
fs.writeFileSync(realPath, nextContent, 'utf8');
|
|
4051
|
+
const savedStat = fs.statSync(realPath);
|
|
4052
|
+
process.stdout.write(JSON.stringify({
|
|
4053
|
+
path: realPath,
|
|
4054
|
+
saved: true,
|
|
4055
|
+
size: savedStat.size,
|
|
4056
|
+
etag: crypto.createHash('sha256').update(Buffer.from(nextContent, 'utf8')).digest('hex')
|
|
4057
|
+
}));
|
|
4058
|
+
}
|
|
3956
4059
|
} catch (e) {
|
|
3957
4060
|
process.stdout.write(JSON.stringify({
|
|
3958
4061
|
error: e && e.message ? e.message : '保存文件失败'
|
|
@@ -4243,22 +4346,140 @@ async function readJsonBody(req) {
|
|
|
4243
4346
|
}
|
|
4244
4347
|
}
|
|
4245
4348
|
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4349
|
+
// 前端发送前为提示词生成的 id:服务端原样存进 user 消息,前端据此判断“这条是不是已经发出去了”
|
|
4350
|
+
function readClientMessageId(res, payload) {
|
|
4351
|
+
const raw = payload && payload.clientMessageId;
|
|
4352
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
4353
|
+
return { value: '' };
|
|
4354
|
+
}
|
|
4355
|
+
if (typeof raw !== 'string' || !messagesView.CLIENT_MESSAGE_ID_PATTERN.test(raw)) {
|
|
4356
|
+
sendJson(res, 400, { error: 'clientMessageId 格式不正确' });
|
|
4357
|
+
return null;
|
|
4358
|
+
}
|
|
4359
|
+
return { value: raw };
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
// 同一个 clientMessageId 已经出现在该 agent 的消息里:这条提示词服务端已经收到过,不能再执行一遍
|
|
4363
|
+
// (前端在网络中断后重发同一条时,靠它兜住“服务端其实已经收到”的情况)
|
|
4364
|
+
function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
|
|
4365
|
+
if (!clientMessageId) {
|
|
4366
|
+
return false;
|
|
4367
|
+
}
|
|
4368
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
4369
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
4370
|
+
const duplicated = Boolean(agentSession) && agentSession.messages.some(
|
|
4371
|
+
message => message && message.role === 'user' && message.clientMessageId === clientMessageId
|
|
4372
|
+
);
|
|
4373
|
+
if (!duplicated) {
|
|
4374
|
+
return false;
|
|
4375
|
+
}
|
|
4376
|
+
sendJson(res, 409, { error: '这条消息已经发出过', duplicate: true });
|
|
4377
|
+
return true;
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
// 检查重复到 user 消息落盘之间可能有 await(容器启动、网络规则就绪):并发的同一条请求也要挡住
|
|
4381
|
+
const inflightClientMessages = new Set();
|
|
4382
|
+
|
|
4383
|
+
// 返回 release():消息落盘(或失败)后调用;已是重复则回 409 并返回 null
|
|
4384
|
+
function claimClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
|
|
4385
|
+
if (!clientMessageId) {
|
|
4386
|
+
return () => {};
|
|
4387
|
+
}
|
|
4388
|
+
if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId)) {
|
|
4389
|
+
return null;
|
|
4390
|
+
}
|
|
4391
|
+
const key = `${path.resolve(webHistoryDir)}|${buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)}|${clientMessageId}`;
|
|
4392
|
+
if (inflightClientMessages.has(key)) {
|
|
4393
|
+
sendJson(res, 409, { error: '这条消息已经发出过', duplicate: true });
|
|
4394
|
+
return null;
|
|
4395
|
+
}
|
|
4396
|
+
inflightClientMessages.add(key);
|
|
4397
|
+
return () => inflightClientMessages.delete(key);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
const COMPRESS_MIN_BYTES = 1024;
|
|
4401
|
+
|
|
4402
|
+
function acceptsGzip(req) {
|
|
4403
|
+
return Boolean(req && req.headers && /\bgzip\b/i.test(String(req.headers['accept-encoding'] || '')));
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
function etagMatches(req, etag) {
|
|
4407
|
+
const header = req && req.headers ? String(req.headers['if-none-match'] || '') : '';
|
|
4408
|
+
if (!header) {
|
|
4409
|
+
return false;
|
|
4410
|
+
}
|
|
4411
|
+
if (header.trim() === '*') {
|
|
4412
|
+
return true;
|
|
4413
|
+
}
|
|
4414
|
+
const strip = value => value.trim().replace(/^W\//, '');
|
|
4415
|
+
return header.split(',').some(candidate => strip(candidate) === strip(etag));
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
// 统一的响应出口:可选 ETag(命中 If-None-Match 回 304)+ >1KB 的内容异步 gzip。
|
|
4419
|
+
// 只用于一次性 JSON/HTML;NDJSON 流和 WebSocket 不走这里,压缩会让它们被缓冲、失去实时性。
|
|
4420
|
+
function sendBody(res, statusCode, contentType, body, extraHeaders = {}, options = {}) {
|
|
4421
|
+
const req = res.req;
|
|
4422
|
+
const headers = {
|
|
4423
|
+
'Content-Type': contentType,
|
|
4424
|
+
'Cache-Control': options.etag ? 'no-cache' : 'no-store',
|
|
4425
|
+
Vary: 'Accept-Encoding',
|
|
4250
4426
|
...extraHeaders
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4427
|
+
};
|
|
4428
|
+
if (options.etag) {
|
|
4429
|
+
headers.ETag = options.etag;
|
|
4430
|
+
if (statusCode === 200 && etagMatches(req, options.etag)) {
|
|
4431
|
+
delete headers['Content-Type'];
|
|
4432
|
+
res.writeHead(304, headers);
|
|
4433
|
+
res.end();
|
|
4434
|
+
return;
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf-8');
|
|
4438
|
+
const finish = (payload, encoding) => {
|
|
4439
|
+
if (res.destroyed || res.writableEnded) {
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
4442
|
+
if (encoding) {
|
|
4443
|
+
headers['Content-Encoding'] = encoding;
|
|
4444
|
+
}
|
|
4445
|
+
headers['Content-Length'] = payload.length;
|
|
4446
|
+
res.writeHead(statusCode, headers);
|
|
4447
|
+
res.end(payload);
|
|
4448
|
+
};
|
|
4449
|
+
if (acceptsGzip(req) && buffer.length > COMPRESS_MIN_BYTES) {
|
|
4450
|
+
const compressed = options.gzipBuffer
|
|
4451
|
+
? Promise.resolve(options.gzipBuffer)
|
|
4452
|
+
: new Promise((resolve, reject) => {
|
|
4453
|
+
zlib.gzip(buffer, { level: 6 }, (error, result) => (error ? reject(error) : resolve(result)));
|
|
4454
|
+
});
|
|
4455
|
+
compressed.then(result => finish(result, 'gzip'), () => finish(buffer, ''));
|
|
4456
|
+
return;
|
|
4457
|
+
}
|
|
4458
|
+
finish(buffer, '');
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
// options.etag 传入时按它做条件请求;options.autoEtag 则用响应体的 sha1(响应体小、生成成本低的接口用)
|
|
4462
|
+
function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
|
|
4463
|
+
const text = JSON.stringify(payload);
|
|
4464
|
+
const etag = options.etag
|
|
4465
|
+
|| (options.autoEtag === true && statusCode === 200
|
|
4466
|
+
? `W/"${crypto.createHash('sha1').update(text).digest('hex')}"`
|
|
4467
|
+
: '');
|
|
4468
|
+
sendBody(res, statusCode, 'application/json; charset=utf-8', text, extraHeaders, { etag });
|
|
4253
4469
|
}
|
|
4254
4470
|
|
|
4255
4471
|
function sendNdjson(res, payload) {
|
|
4256
4472
|
res.write(`${JSON.stringify(payload)}\n`);
|
|
4257
4473
|
}
|
|
4258
4474
|
|
|
4475
|
+
const WEB_STREAM_FLUSH_EVENT_TYPES = new Set(['process.exited', 'process.interrupted', 'agent.turn.failed']);
|
|
4476
|
+
|
|
4259
4477
|
function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
|
|
4260
4478
|
return (payload, type, data = {}) => {
|
|
4261
|
-
|
|
4479
|
+
// 流式过程中的事件只改内存(最迟 1 秒落盘);结束类事件立即写盘,顺带写出之前攒着的内容
|
|
4480
|
+
const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data, {
|
|
4481
|
+
defer: !WEB_STREAM_FLUSH_EVENT_TYPES.has(type)
|
|
4482
|
+
});
|
|
4262
4483
|
sendNdjson(res, { ...payload, controlEvent });
|
|
4263
4484
|
};
|
|
4264
4485
|
}
|
|
@@ -4347,13 +4568,8 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
|
|
|
4347
4568
|
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
4348
4569
|
}
|
|
4349
4570
|
|
|
4350
|
-
function sendHtml(res, statusCode, html, extraHeaders = {}) {
|
|
4351
|
-
res
|
|
4352
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
4353
|
-
'Cache-Control': 'no-store',
|
|
4354
|
-
...extraHeaders
|
|
4355
|
-
});
|
|
4356
|
-
res.end(html);
|
|
4571
|
+
function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
|
|
4572
|
+
sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
|
|
4357
4573
|
}
|
|
4358
4574
|
|
|
4359
4575
|
const SETUP_TEST_TIMEOUT_SECONDS = 60;
|
|
@@ -4738,11 +4954,54 @@ function buildSessionAudit(ctx, state, sessionRef) {
|
|
|
4738
4954
|
};
|
|
4739
4955
|
}
|
|
4740
4956
|
|
|
4957
|
+
// 按文件 mtime + size 缓存 index.html 原文(2MB,之前每次请求都同步读一遍)
|
|
4958
|
+
let indexHtmlCache = { key: '', html: '', variants: new Map() };
|
|
4959
|
+
|
|
4741
4960
|
function loadIndexHtml() {
|
|
4742
|
-
|
|
4961
|
+
let stat = null;
|
|
4962
|
+
try {
|
|
4963
|
+
stat = fs.statSync(INDEX_HTML_PATH);
|
|
4964
|
+
} catch (e) {
|
|
4743
4965
|
return '<h1>Template Not Found</h1>';
|
|
4744
4966
|
}
|
|
4745
|
-
|
|
4967
|
+
const key = `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
|
|
4968
|
+
if (indexHtmlCache.key !== key) {
|
|
4969
|
+
indexHtmlCache = { key, html: fs.readFileSync(INDEX_HTML_PATH, 'utf-8'), variants: new Map() };
|
|
4970
|
+
}
|
|
4971
|
+
return indexHtmlCache.html;
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
// 渲染结果(含 serveTitle 处理)+ ETag + gzip 都缓存,文件没变就不重算
|
|
4975
|
+
function getIndexHtmlVariant(kind, ctx) {
|
|
4976
|
+
const html = loadIndexHtml();
|
|
4977
|
+
const variantKey = `${kind}:${typeof ctx.serveTitle === 'string' ? ctx.serveTitle : ''}`;
|
|
4978
|
+
let variant = indexHtmlCache.variants.get(variantKey);
|
|
4979
|
+
if (!variant) {
|
|
4980
|
+
const rendered = kind === 'app' ? renderAppHtml(ctx) : applyServeTitle(html, ctx);
|
|
4981
|
+
variant = {
|
|
4982
|
+
html: rendered,
|
|
4983
|
+
etag: `W/"${crypto.createHash('sha1').update(rendered).digest('hex')}"`,
|
|
4984
|
+
gzipBuffer: null
|
|
4985
|
+
};
|
|
4986
|
+
indexHtmlCache.variants.set(variantKey, variant);
|
|
4987
|
+
}
|
|
4988
|
+
return variant;
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
function sendIndexHtml(res, kind, ctx, extraHeaders = {}) {
|
|
4992
|
+
const variant = getIndexHtmlVariant(kind, ctx);
|
|
4993
|
+
const options = { etag: variant.etag };
|
|
4994
|
+
if (variant.gzipBuffer) {
|
|
4995
|
+
options.gzipBuffer = variant.gzipBuffer;
|
|
4996
|
+
} else if (acceptsGzip(res.req)) {
|
|
4997
|
+
// 第一次压缩的结果留着给后面的请求
|
|
4998
|
+
zlib.gzip(Buffer.from(variant.html, 'utf-8'), { level: 6 }, (error, result) => {
|
|
4999
|
+
if (!error) {
|
|
5000
|
+
variant.gzipBuffer = result;
|
|
5001
|
+
}
|
|
5002
|
+
});
|
|
5003
|
+
}
|
|
5004
|
+
sendHtml(res, 200, variant.html, extraHeaders, options);
|
|
4746
5005
|
}
|
|
4747
5006
|
|
|
4748
5007
|
function escapeHtmlText(value) {
|
|
@@ -4823,7 +5082,31 @@ function sendTerminalEvent(ws, type, payload = {}) {
|
|
|
4823
5082
|
ws.send(JSON.stringify({ type, ...payload }));
|
|
4824
5083
|
}
|
|
4825
5084
|
|
|
4826
|
-
|
|
5085
|
+
// resize 消息的数值必须是整数且在范围内,否则整条忽略(不夹到边界,避免把异常值悄悄变成合法值)
|
|
5086
|
+
function parseTerminalResize(cols, rows) {
|
|
5087
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows)) {
|
|
5088
|
+
return null;
|
|
5089
|
+
}
|
|
5090
|
+
if (cols < WEB_TERMINAL_RESIZE_MIN_COLS || cols > WEB_TERMINAL_MAX_COLS
|
|
5091
|
+
|| rows < WEB_TERMINAL_RESIZE_MIN_ROWS || rows > WEB_TERMINAL_MAX_ROWS) {
|
|
5092
|
+
return null;
|
|
5093
|
+
}
|
|
5094
|
+
return { cols, rows };
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
// 容器里跑的固定脚本,参数只有三个位置参数(终端 id、列、行),不经过 shell 拼接。
|
|
5098
|
+
// tty 文件在容器里,容器内的进程可以改写它,所以内容只当不可信输入:
|
|
5099
|
+
// 必须是普通文件(不跟符号链接、不碰 FIFO),读最多 64 字节,且整行匹配 /dev/pts/<数字> 才会执行 stty。
|
|
5100
|
+
const TERMINAL_RESIZE_SCRIPT = [
|
|
5101
|
+
'f="/tmp/manyoyo-term-$1.tty"',
|
|
5102
|
+
'[ -f "$f" ] && [ ! -L "$f" ] || exit 3',
|
|
5103
|
+
't="$(head -c 64 "$f" 2>/dev/null | head -n 1)"',
|
|
5104
|
+
'printf \'%s\\n\' "$t" | grep -Eq \'^/dev/pts/[0-9]+$\' || exit 4',
|
|
5105
|
+
'case "$2$3" in *[!0-9]*) exit 5;; esac',
|
|
5106
|
+
'exec stty -F "$t" cols "$2" rows "$3"'
|
|
5107
|
+
].join('\n');
|
|
5108
|
+
|
|
5109
|
+
function spawnWebTerminalProcess(ctx, containerName, cols, rows, termId) {
|
|
4827
5110
|
const terminalBootstrap = [
|
|
4828
5111
|
'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
|
|
4829
5112
|
'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
|
|
@@ -4836,6 +5119,10 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4836
5119
|
'if [ -f /run/manyoyo-sys/env.sh ]; then',
|
|
4837
5120
|
' . /run/manyoyo-sys/env.sh',
|
|
4838
5121
|
'fi',
|
|
5122
|
+
'if [ -n "${MANYOYO_TERM_ID:-}" ]; then',
|
|
5123
|
+
' tty > "/tmp/manyoyo-term-$MANYOYO_TERM_ID.tty" 2>/dev/null || true',
|
|
5124
|
+
' trap \'rm -f "/tmp/manyoyo-term-$MANYOYO_TERM_ID.tty"\' EXIT',
|
|
5125
|
+
'fi',
|
|
4839
5126
|
'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
|
|
4840
5127
|
' COLUMNS="$MANYOYO_TERM_COLS"',
|
|
4841
5128
|
' LINES="$MANYOYO_TERM_ROWS"',
|
|
@@ -4865,7 +5152,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4865
5152
|
`TERM=${termValue}`,
|
|
4866
5153
|
`COLORTERM=${colorTermValue}`,
|
|
4867
5154
|
`MANYOYO_TERM_COLS=${String(cols)}`,
|
|
4868
|
-
`MANYOYO_TERM_ROWS=${String(rows)}
|
|
5155
|
+
`MANYOYO_TERM_ROWS=${String(rows)}`,
|
|
5156
|
+
`MANYOYO_TERM_ID=${termId}`
|
|
4869
5157
|
],
|
|
4870
5158
|
command: ['/bin/bash', '-lc', terminalBootstrap]
|
|
4871
5159
|
}, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -4873,7 +5161,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4873
5161
|
|
|
4874
5162
|
function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
4875
5163
|
const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
4876
|
-
const
|
|
5164
|
+
const termId = crypto.randomBytes(8).toString('hex');
|
|
5165
|
+
const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows, termId);
|
|
4877
5166
|
const session = {
|
|
4878
5167
|
id: sessionId,
|
|
4879
5168
|
containerName,
|
|
@@ -4916,6 +5205,10 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4916
5205
|
}
|
|
4917
5206
|
session.closing = true;
|
|
4918
5207
|
clearInterval(pingTimer);
|
|
5208
|
+
if (resizeState.timer) {
|
|
5209
|
+
clearTimeout(resizeState.timer);
|
|
5210
|
+
resizeState.timer = null;
|
|
5211
|
+
}
|
|
4919
5212
|
state.terminalSessions.delete(sessionId);
|
|
4920
5213
|
if (ptyProcess && !ptyProcess.killed) {
|
|
4921
5214
|
ptyProcess.kill('SIGTERM');
|
|
@@ -4927,6 +5220,72 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4927
5220
|
}
|
|
4928
5221
|
};
|
|
4929
5222
|
|
|
5223
|
+
// 把前端新的窗口大小同步给容器里的伪终端,内核会自动给前台进程发 SIGWINCH。
|
|
5224
|
+
// 数值严格校验;节流:同一时刻只有一个 exec 在跑,期间的新尺寸只保留最后一个
|
|
5225
|
+
const resizeState = { running: false, pending: null, last: null, lastAt: 0, timer: null, retries: 0 };
|
|
5226
|
+
const runTerminalResize = () => {
|
|
5227
|
+
if (resizeState.running || !resizeState.pending || session.closing) {
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5230
|
+
const wait = resizeState.lastAt + WEB_TERMINAL_RESIZE_MIN_INTERVAL_MS - Date.now();
|
|
5231
|
+
if (wait > 0) {
|
|
5232
|
+
if (!resizeState.timer) {
|
|
5233
|
+
resizeState.timer = setTimeout(() => {
|
|
5234
|
+
resizeState.timer = null;
|
|
5235
|
+
runTerminalResize();
|
|
5236
|
+
}, wait);
|
|
5237
|
+
}
|
|
5238
|
+
return;
|
|
5239
|
+
}
|
|
5240
|
+
const size = resizeState.pending;
|
|
5241
|
+
resizeState.pending = null;
|
|
5242
|
+
resizeState.running = true;
|
|
5243
|
+
resizeState.lastAt = Date.now();
|
|
5244
|
+
let child = null;
|
|
5245
|
+
let killTimer = null;
|
|
5246
|
+
const done = code => {
|
|
5247
|
+
if (!resizeState.running) {
|
|
5248
|
+
return;
|
|
5249
|
+
}
|
|
5250
|
+
clearTimeout(killTimer);
|
|
5251
|
+
resizeState.running = false;
|
|
5252
|
+
resizeState.lastAt = Date.now();
|
|
5253
|
+
if (code !== 0 && !resizeState.pending && !session.closing && resizeState.retries < 3) {
|
|
5254
|
+
// 失败(比如 tty 文件还没写好):同尺寸的上报会被去重丢掉,所以这里自己重试几次
|
|
5255
|
+
resizeState.retries += 1;
|
|
5256
|
+
resizeState.pending = size;
|
|
5257
|
+
} else if (code === 0) {
|
|
5258
|
+
resizeState.retries = 0;
|
|
5259
|
+
}
|
|
5260
|
+
runTerminalResize();
|
|
5261
|
+
};
|
|
5262
|
+
try {
|
|
5263
|
+
child = spawnContainerExec(ctx, containerName, {
|
|
5264
|
+
command: ['/bin/bash', '-c', TERMINAL_RESIZE_SCRIPT, 'manyoyo-resize', termId, String(size.cols), String(size.rows)]
|
|
5265
|
+
}, { stdio: 'ignore' });
|
|
5266
|
+
} catch (e) {
|
|
5267
|
+
done(1);
|
|
5268
|
+
return;
|
|
5269
|
+
}
|
|
5270
|
+
killTimer = setTimeout(() => {
|
|
5271
|
+
try { child.kill('SIGKILL'); } catch (e) { /* 已经退出 */ }
|
|
5272
|
+
}, 5000);
|
|
5273
|
+
child.once('close', code => done(code));
|
|
5274
|
+
child.once('error', () => done(1));
|
|
5275
|
+
};
|
|
5276
|
+
const requestTerminalResize = (rawCols, rawRows) => {
|
|
5277
|
+
const size = parseTerminalResize(rawCols, rawRows);
|
|
5278
|
+
if (!size) {
|
|
5279
|
+
return;
|
|
5280
|
+
}
|
|
5281
|
+
if (resizeState.last && resizeState.last.cols === size.cols && resizeState.last.rows === size.rows) {
|
|
5282
|
+
return;
|
|
5283
|
+
}
|
|
5284
|
+
resizeState.last = size;
|
|
5285
|
+
resizeState.pending = size;
|
|
5286
|
+
runTerminalResize();
|
|
5287
|
+
};
|
|
5288
|
+
|
|
4930
5289
|
ptyProcess.stdout.on('data', chunk => {
|
|
4931
5290
|
sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
|
|
4932
5291
|
});
|
|
@@ -4973,7 +5332,7 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4973
5332
|
}
|
|
4974
5333
|
|
|
4975
5334
|
if (payload.type === 'resize') {
|
|
4976
|
-
|
|
5335
|
+
requestTerminalResize(payload.cols, payload.rows);
|
|
4977
5336
|
return;
|
|
4978
5337
|
}
|
|
4979
5338
|
|
|
@@ -5037,7 +5396,7 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
|
5037
5396
|
});
|
|
5038
5397
|
return true;
|
|
5039
5398
|
}
|
|
5040
|
-
|
|
5399
|
+
sendIndexHtml(res, 'login', ctx, { 'Referrer-Policy': 'no-referrer' });
|
|
5041
5400
|
return true;
|
|
5042
5401
|
}
|
|
5043
5402
|
|
|
@@ -5265,8 +5624,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5265
5624
|
sendJson(res, 400, { error: '配置内容不能为空' });
|
|
5266
5625
|
return;
|
|
5267
5626
|
}
|
|
5627
|
+
const ifMatch = req.headers['if-match'];
|
|
5628
|
+
if (ifMatch === undefined) {
|
|
5629
|
+
sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
|
|
5630
|
+
return;
|
|
5631
|
+
}
|
|
5268
5632
|
|
|
5633
|
+
// 从这里到写盘之间不能有 await:读文件、比对版本、写文件在同一个同步段里完成,并发的两个请求不会同时通过检查
|
|
5269
5634
|
const currentSnapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
5635
|
+
const currentEtag = computeContentEtag(currentSnapshot.raw);
|
|
5636
|
+
if (!ifMatchSatisfied(ifMatch, currentEtag)) {
|
|
5637
|
+
sendJson(res, 409, {
|
|
5638
|
+
error: '配置文件已被其他地方修改',
|
|
5639
|
+
conflict: true,
|
|
5640
|
+
...buildSafeWebConfigSnapshot(currentSnapshot, ctx)
|
|
5641
|
+
});
|
|
5642
|
+
return;
|
|
5643
|
+
}
|
|
5270
5644
|
let finalRaw = raw;
|
|
5271
5645
|
let parsed = null;
|
|
5272
5646
|
try {
|
|
@@ -5283,6 +5657,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5283
5657
|
sendJson(res, 200, {
|
|
5284
5658
|
saved: true,
|
|
5285
5659
|
path: savePath,
|
|
5660
|
+
etag: computeContentEtag(finalRaw),
|
|
5286
5661
|
defaults: buildConfigDefaults(ctx, parsed)
|
|
5287
5662
|
});
|
|
5288
5663
|
}
|
|
@@ -5603,7 +5978,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5603
5978
|
})
|
|
5604
5979
|
.sort(compareWebSessionCreatedDesc);
|
|
5605
5980
|
|
|
5606
|
-
sendJson(res, 200, { sessions });
|
|
5981
|
+
sendJson(res, 200, { sessions }, {}, { autoEtag: true });
|
|
5607
5982
|
}
|
|
5608
5983
|
},
|
|
5609
5984
|
{
|
|
@@ -5743,14 +6118,62 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5743
6118
|
if (!sessionRef) {
|
|
5744
6119
|
return;
|
|
5745
6120
|
}
|
|
6121
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
6122
|
+
const query = messagesView.parseMessagesQuery(requestUrl.searchParams);
|
|
6123
|
+
if (query.error) {
|
|
6124
|
+
sendJson(res, 400, { error: query.error });
|
|
6125
|
+
return;
|
|
6126
|
+
}
|
|
5746
6127
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5747
6128
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
5748
6129
|
|| createEmptyWebAgentSession(sessionRef.agentId);
|
|
6130
|
+
// 版本号来自历史缓存的进程内计数:不用算响应体就能判断 304,也不会像 mtime 那样在同一毫秒内撞车
|
|
6131
|
+
const version = getWebSessionHistoryVersion(state.webHistoryDir, sessionRef.containerName);
|
|
6132
|
+
const queryDigest = crypto.createHash('sha1')
|
|
6133
|
+
.update(`${sessionRef.agentId}|${requestUrl.search}`)
|
|
6134
|
+
.digest('hex')
|
|
6135
|
+
.slice(0, 12);
|
|
6136
|
+
const etag = `W/"${historyStore.bootId}.${version}.${queryDigest}"`;
|
|
6137
|
+
if (etagMatches(req, etag)) {
|
|
6138
|
+
sendJson(res, 200, {}, {}, { etag });
|
|
6139
|
+
return;
|
|
6140
|
+
}
|
|
6141
|
+
const view = messagesView.buildMessagesResponse(agentSession.messages, agentSession.epoch, query);
|
|
5749
6142
|
sendJson(res, 200, {
|
|
5750
6143
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5751
6144
|
containerName: sessionRef.containerName,
|
|
5752
6145
|
agentId: sessionRef.agentId,
|
|
5753
|
-
|
|
6146
|
+
...view
|
|
6147
|
+
}, {}, { etag });
|
|
6148
|
+
}
|
|
6149
|
+
},
|
|
6150
|
+
{
|
|
6151
|
+
method: 'GET',
|
|
6152
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/([^/]+)\/trace$/),
|
|
6153
|
+
handler: async match => {
|
|
6154
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
6155
|
+
if (!sessionRef) {
|
|
6156
|
+
return;
|
|
6157
|
+
}
|
|
6158
|
+
const messageId = match[2];
|
|
6159
|
+
if (!messagesView.MESSAGE_ID_PATTERN.test(messageId)) {
|
|
6160
|
+
sendJson(res, 400, { error: '消息 id 格式不正确' });
|
|
6161
|
+
return;
|
|
6162
|
+
}
|
|
6163
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
6164
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
6165
|
+
const message = agentSession
|
|
6166
|
+
? agentSession.messages.find(item => item && item.id === messageId && item.streamTrace === true)
|
|
6167
|
+
: null;
|
|
6168
|
+
if (!message) {
|
|
6169
|
+
sendJson(res, 404, { error: '没有找到这条执行过程' });
|
|
6170
|
+
return;
|
|
6171
|
+
}
|
|
6172
|
+
sendJson(res, 200, {
|
|
6173
|
+
id: message.id,
|
|
6174
|
+
content: message.content,
|
|
6175
|
+
traceEvents: Array.isArray(message.traceEvents) ? message.traceEvents : [],
|
|
6176
|
+
pending: message.pending === true
|
|
5754
6177
|
});
|
|
5755
6178
|
}
|
|
5756
6179
|
},
|
|
@@ -5905,12 +6328,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5905
6328
|
return;
|
|
5906
6329
|
}
|
|
5907
6330
|
|
|
6331
|
+
const ifMatch = req.headers['if-match'];
|
|
6332
|
+
if (ifMatch === undefined) {
|
|
6333
|
+
sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
|
|
6334
|
+
return;
|
|
6335
|
+
}
|
|
6336
|
+
|
|
5908
6337
|
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
5909
6338
|
const result = await execJsonCommandInWebContainer(
|
|
5910
6339
|
ctx,
|
|
5911
6340
|
sessionRef.containerName,
|
|
5912
|
-
buildContainerFileWriteCommand(targetPath, content)
|
|
6341
|
+
buildContainerFileWriteCommand(targetPath, content, normalizeIfMatchHeader(ifMatch))
|
|
5913
6342
|
);
|
|
6343
|
+
if (result && result.conflict === true) {
|
|
6344
|
+
sendJson(res, 409, { error: result.error || '文件已在容器里被修改', conflict: true });
|
|
6345
|
+
return;
|
|
6346
|
+
}
|
|
5914
6347
|
if (result && result.error) {
|
|
5915
6348
|
sendJson(res, 400, { error: result.error });
|
|
5916
6349
|
return;
|
|
@@ -5985,7 +6418,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5985
6418
|
|
|
5986
6419
|
const containerMap = listWebManyoyoContainers(ctx);
|
|
5987
6420
|
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
5988
|
-
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
|
|
6421
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
|
|
5989
6422
|
}
|
|
5990
6423
|
},
|
|
5991
6424
|
{
|
|
@@ -6146,9 +6579,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6146
6579
|
sendJson(res, 400, { error: 'command 不能为空' });
|
|
6147
6580
|
return;
|
|
6148
6581
|
}
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6582
|
+
const commandClientId = readClientMessageId(res, payload);
|
|
6583
|
+
if (!commandClientId) {
|
|
6584
|
+
return;
|
|
6585
|
+
}
|
|
6586
|
+
const releaseCommandClaim = claimClientMessage(res, state.webHistoryDir, sessionRef, commandClientId.value);
|
|
6587
|
+
if (!releaseCommandClaim) {
|
|
6588
|
+
return;
|
|
6589
|
+
}
|
|
6590
|
+
try {
|
|
6591
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
6592
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', command, {
|
|
6593
|
+
mode: 'command',
|
|
6594
|
+
...(commandClientId.value ? { clientMessageId: commandClientId.value } : {})
|
|
6595
|
+
});
|
|
6596
|
+
} finally {
|
|
6597
|
+
releaseCommandClaim();
|
|
6598
|
+
}
|
|
6152
6599
|
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
|
|
6153
6600
|
// role 用 'system'、带 mode: 'command',与前端实时发送时的约定对齐(workspace-panel.tsx
|
|
6154
6601
|
// handleSend 的 command 分支)——命令输出本来就不该走 assistant 的气泡样式和 markdown 渲染,
|
|
@@ -6178,11 +6625,20 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6178
6625
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
6179
6626
|
return;
|
|
6180
6627
|
}
|
|
6628
|
+
const agentClientId = readClientMessageId(res, payload);
|
|
6629
|
+
if (!agentClientId) {
|
|
6630
|
+
return;
|
|
6631
|
+
}
|
|
6632
|
+
const releaseAgentClaim = claimClientMessage(res, state.webHistoryDir, sessionRef, agentClientId.value);
|
|
6633
|
+
if (!releaseAgentClaim) {
|
|
6634
|
+
return;
|
|
6635
|
+
}
|
|
6181
6636
|
|
|
6182
6637
|
let prepared = null;
|
|
6183
6638
|
try {
|
|
6184
6639
|
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
6185
6640
|
} catch (e) {
|
|
6641
|
+
releaseAgentClaim();
|
|
6186
6642
|
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
6187
6643
|
return;
|
|
6188
6644
|
}
|
|
@@ -6190,8 +6646,10 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6190
6646
|
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
|
|
6191
6647
|
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
6192
6648
|
mode: 'agent',
|
|
6193
|
-
contextMode
|
|
6649
|
+
contextMode,
|
|
6650
|
+
...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
|
|
6194
6651
|
});
|
|
6652
|
+
releaseAgentClaim();
|
|
6195
6653
|
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
|
|
6196
6654
|
agentProgram: agentMeta.agentProgram
|
|
6197
6655
|
});
|
|
@@ -6227,6 +6685,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6227
6685
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
6228
6686
|
return;
|
|
6229
6687
|
}
|
|
6688
|
+
const streamClientId = readClientMessageId(res, payload);
|
|
6689
|
+
if (!streamClientId) {
|
|
6690
|
+
return;
|
|
6691
|
+
}
|
|
6692
|
+
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value)) {
|
|
6693
|
+
return;
|
|
6694
|
+
}
|
|
6230
6695
|
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
6231
6696
|
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
6232
6697
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
@@ -6237,7 +6702,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6237
6702
|
|
|
6238
6703
|
const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
6239
6704
|
mode: 'agent',
|
|
6240
|
-
pending: true
|
|
6705
|
+
pending: true,
|
|
6706
|
+
...(streamClientId.value ? { clientMessageId: streamClientId.value } : {})
|
|
6241
6707
|
});
|
|
6242
6708
|
const traceMessage = appendWebAgentTraceMessage(
|
|
6243
6709
|
state.webHistoryDir,
|
|
@@ -6365,7 +6831,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6365
6831
|
content: traceLines.join('\n'),
|
|
6366
6832
|
traceEvents: traceEvents.slice(),
|
|
6367
6833
|
pending: true
|
|
6368
|
-
});
|
|
6834
|
+
}, { defer: true });
|
|
6369
6835
|
}
|
|
6370
6836
|
if (event && event.type === 'content_delta' && typeof event.content === 'string') {
|
|
6371
6837
|
const now = Date.now();
|
|
@@ -6391,7 +6857,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6391
6857
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
6392
6858
|
content: event.content,
|
|
6393
6859
|
pending: true
|
|
6394
|
-
});
|
|
6860
|
+
}, { defer: true });
|
|
6395
6861
|
lastPartialPersistAt = now;
|
|
6396
6862
|
}
|
|
6397
6863
|
}
|
|
@@ -6741,7 +7207,7 @@ async function startWebServer(options) {
|
|
|
6741
7207
|
|
|
6742
7208
|
// 前端构建产物见 frontend/(npm run build:web),输出到 lib/web/index.html
|
|
6743
7209
|
if (req.method === 'GET' && pathname === '/') {
|
|
6744
|
-
|
|
7210
|
+
sendIndexHtml(res, 'app', ctx);
|
|
6745
7211
|
return;
|
|
6746
7212
|
}
|
|
6747
7213
|
|
|
@@ -6917,6 +7383,7 @@ async function startWebServer(options) {
|
|
|
6917
7383
|
port: listenPort,
|
|
6918
7384
|
close: () => new Promise(resolve => {
|
|
6919
7385
|
ctx.logger.info('web server closing');
|
|
7386
|
+
flushWebSessionHistories();
|
|
6920
7387
|
if (state.networkWatch) {
|
|
6921
7388
|
state.networkWatch.stop();
|
|
6922
7389
|
state.networkWatch = null;
|
|
@@ -6956,5 +7423,14 @@ async function startWebServer(options) {
|
|
|
6956
7423
|
|
|
6957
7424
|
module.exports = {
|
|
6958
7425
|
startWebServer,
|
|
6959
|
-
buildEnvModelOptions
|
|
7426
|
+
buildEnvModelOptions,
|
|
7427
|
+
// 仅供测试:直接驱动历史读写层
|
|
7428
|
+
__testing: {
|
|
7429
|
+
appendWebSessionMessage,
|
|
7430
|
+
appendWebSessionControlEvent,
|
|
7431
|
+
removeWebSessionMessage,
|
|
7432
|
+
loadWebSessionHistory,
|
|
7433
|
+
saveWebSessionHistory,
|
|
7434
|
+
cloneWebAgentSessionsDeep
|
|
7435
|
+
}
|
|
6960
7436
|
};
|