@xcanwin/manyoyo 8.6.0 → 8.6.3
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/container-exec.js +20 -1
- 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 +20 -2
- package/lib/web/history-store.js +221 -0
- package/lib/web/index.html +90 -90
- package/lib/web/messages-view.js +182 -0
- package/lib/web/server.js +646 -86
- package/package.json +1 -1
package/lib/web/server.js
CHANGED
|
@@ -13,7 +13,7 @@ const { buildContainerRunArgs, hasNetworkArg } = require('../container-run');
|
|
|
13
13
|
const { buildContainerIntegration, mergeIntegration } = require('../plugin');
|
|
14
14
|
const { applyAptMirror } = require('../mirrors');
|
|
15
15
|
const containerState = require('../container-state');
|
|
16
|
-
const { buildExecArgs, resolveContainerId } = require('../container-exec');
|
|
16
|
+
const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
|
|
17
17
|
const { createNetworkManager, NETWORK_NAME } = require('../container-network');
|
|
18
18
|
const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../network-policy');
|
|
19
19
|
const { withEnvEndpoints } = require('../network-endpoints');
|
|
@@ -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;
|
|
@@ -829,7 +883,7 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
829
883
|
const hasJson = /(?:^|\s)--json(?:\s|$)/.test(suffix);
|
|
830
884
|
const injectedFlags = hasJson ? '' : ' --json';
|
|
831
885
|
const hasModel = /(?:^|\s)(?:-m|--model)(?:\s|$)/.test(suffix);
|
|
832
|
-
const modelFlag = model && !hasModel ? ` --model ${model}` : '';
|
|
886
|
+
const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
|
|
833
887
|
if (sessionId) {
|
|
834
888
|
const promptIndex = suffix.indexOf('{prompt}');
|
|
835
889
|
const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
|
|
@@ -877,7 +931,7 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
877
931
|
}
|
|
878
932
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
879
933
|
if (model) {
|
|
880
|
-
flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
934
|
+
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
881
935
|
}
|
|
882
936
|
const claudeTemplate = prependAgentFlags(
|
|
883
937
|
templateText,
|
|
@@ -896,7 +950,7 @@ function buildGeminiAgentExecCommand(template, prompt, options = {}) {
|
|
|
896
950
|
];
|
|
897
951
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
898
952
|
if (model) {
|
|
899
|
-
flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
|
|
953
|
+
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
|
|
900
954
|
}
|
|
901
955
|
const geminiTemplate = prependAgentFlags(
|
|
902
956
|
templateText,
|
|
@@ -922,7 +976,7 @@ function buildOpenCodeAgentExecCommand(template, prompt, options = {}) {
|
|
|
922
976
|
}
|
|
923
977
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
924
978
|
if (model) {
|
|
925
|
-
flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
|
|
979
|
+
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
|
|
926
980
|
}
|
|
927
981
|
const opencodeTemplate = prependAgentFlags(
|
|
928
982
|
templateText,
|
|
@@ -1319,6 +1373,61 @@ function extractOpenCodeModelsFromOutput(text) {
|
|
|
1319
1373
|
.map(value => ({ value, label: value, description: '' }));
|
|
1320
1374
|
}
|
|
1321
1375
|
|
|
1376
|
+
// 模型目录要在容器里起一个 Agent 进程才能问到,很慢:按“容器 + Agent 程序”缓存,
|
|
1377
|
+
// 并合并同一时刻的重复请求。缓存挂在 ctx 上(随 serve 实例走),不放模块级,避免测试之间串味
|
|
1378
|
+
const MODEL_CATALOG_TTL_MS = 10 * 60 * 1000;
|
|
1379
|
+
const modelCatalogCaches = new WeakMap();
|
|
1380
|
+
|
|
1381
|
+
async function fetchAgentModelCatalogCached(ctx, containerName, agentProgram) {
|
|
1382
|
+
let cache = modelCatalogCaches.get(ctx);
|
|
1383
|
+
if (!cache) {
|
|
1384
|
+
cache = new Map();
|
|
1385
|
+
modelCatalogCaches.set(ctx, cache);
|
|
1386
|
+
}
|
|
1387
|
+
const key = `${containerName}\u0000${agentProgram}`;
|
|
1388
|
+
const hit = cache.get(key);
|
|
1389
|
+
if (hit && (hit.pending || Date.now() - hit.at < MODEL_CATALOG_TTL_MS)) {
|
|
1390
|
+
return hit.pending || hit.models;
|
|
1391
|
+
}
|
|
1392
|
+
const pending = fetchAgentModelCatalog(ctx, containerName, agentProgram).then(models => {
|
|
1393
|
+
// 探测失败(超时 / 容器没起来)返回空数组,不缓存,下次还能重试
|
|
1394
|
+
if (models.length) cache.set(key, { at: Date.now(), models });
|
|
1395
|
+
else cache.delete(key);
|
|
1396
|
+
return models;
|
|
1397
|
+
}, error => {
|
|
1398
|
+
cache.delete(key);
|
|
1399
|
+
throw error;
|
|
1400
|
+
});
|
|
1401
|
+
cache.set(key, { pending });
|
|
1402
|
+
return pending;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// 以会话实际配置的 env 生成模型选项。defaultModel 是“跟随默认”实际用的模型;
|
|
1406
|
+
// Claude 另有按档位别名(opus / sonnet / haiku …)一行一个的选项:value 是别名(--model opus 由 CLI 按 env 解析),
|
|
1407
|
+
// label 是该档位对应的真实模型名。不同档位指向同一个模型也各占一行,不合并。
|
|
1408
|
+
// 只读 *_MODEL 这类非敏感键,容器可写的 env 只用来展示,不参与任何安全判断
|
|
1409
|
+
const CLAUDE_TIER_ORDER = ['opus', 'fable', 'sonnet', 'haiku'];
|
|
1410
|
+
|
|
1411
|
+
function buildEnvModelOptions(agentProgram, env) {
|
|
1412
|
+
const source = env && typeof env === 'object' ? env : {};
|
|
1413
|
+
const defaultKey = { claude: 'ANTHROPIC_MODEL', codex: 'OPENAI_MODEL', opencode: 'OPENAI_MODEL', gemini: 'GEMINI_MODEL' }[agentProgram];
|
|
1414
|
+
if (!defaultKey) return { defaultModel: '', options: [] };
|
|
1415
|
+
const defaultModel = normalizeStoredAgentModelValue(source[defaultKey]);
|
|
1416
|
+
if (agentProgram !== 'claude') return { defaultModel, options: [] };
|
|
1417
|
+
const tiers = [];
|
|
1418
|
+
Object.keys(source).forEach(key => {
|
|
1419
|
+
const matched = key.match(/^ANTHROPIC_DEFAULT_([A-Z0-9]+)_MODEL$/);
|
|
1420
|
+
const model = matched ? normalizeStoredAgentModelValue(source[key]) : '';
|
|
1421
|
+
if (model) tiers.push({ alias: matched[1].toLowerCase(), model });
|
|
1422
|
+
});
|
|
1423
|
+
const rank = alias => (CLAUDE_TIER_ORDER.includes(alias) ? CLAUDE_TIER_ORDER.indexOf(alias) : CLAUDE_TIER_ORDER.length);
|
|
1424
|
+
tiers.sort((x, y) => rank(x.alias) - rank(y.alias) || x.alias.localeCompare(y.alias));
|
|
1425
|
+
return {
|
|
1426
|
+
defaultModel,
|
|
1427
|
+
options: tiers.map(tier => ({ value: tier.alias, label: tier.model, description: tier.alias }))
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1322
1431
|
async function fetchAgentModelCatalog(ctx, containerName, agentProgram) {
|
|
1323
1432
|
if (agentProgram === 'claude') {
|
|
1324
1433
|
const requestLine = JSON.stringify({
|
|
@@ -2405,6 +2514,24 @@ function redactConfigObject(value) {
|
|
|
2405
2514
|
return result;
|
|
2406
2515
|
}
|
|
2407
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
|
+
|
|
2408
2535
|
function buildSafeWebConfigSnapshot(snapshot, ctx) {
|
|
2409
2536
|
const parsed = snapshot && snapshot.parseError ? {} : toPlainObject(snapshot && snapshot.parsed);
|
|
2410
2537
|
let raw = '';
|
|
@@ -2425,6 +2552,8 @@ function buildSafeWebConfigSnapshot(snapshot, ctx) {
|
|
|
2425
2552
|
return {
|
|
2426
2553
|
path: snapshot && snapshot.path ? snapshot.path : path.resolve(getDefaultWebConfigPath()),
|
|
2427
2554
|
raw,
|
|
2555
|
+
// 版本号 = 磁盘原文的 sha256(不是脱敏后的文本:只有密钥变了也要能识别),只返回哈希,不泄露内容
|
|
2556
|
+
etag: computeContentEtag(snapshot && typeof snapshot.raw === 'string' ? snapshot.raw : ''),
|
|
2428
2557
|
parsed: redactConfigObject(parsed),
|
|
2429
2558
|
defaults: redactConfigObject(buildConfigDefaults(ctx, parsed)),
|
|
2430
2559
|
parseError: snapshot && snapshot.parseError ? snapshot.parseError : null,
|
|
@@ -3066,6 +3195,7 @@ function cloneWebAgentSessionsDeep(sourceHistory) {
|
|
|
3066
3195
|
: {};
|
|
3067
3196
|
Object.keys(agents).forEach(agentId => {
|
|
3068
3197
|
cloned[agentId] = JSON.parse(JSON.stringify(agents[agentId]));
|
|
3198
|
+
cloned[agentId].epoch = messagesView.createEpoch();
|
|
3069
3199
|
});
|
|
3070
3200
|
return cloned;
|
|
3071
3201
|
}
|
|
@@ -3782,8 +3912,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
|
|
|
3782
3912
|
const fs = require('fs');
|
|
3783
3913
|
const path = require('path');
|
|
3784
3914
|
|
|
3915
|
+
const crypto = require('crypto');
|
|
3916
|
+
|
|
3785
3917
|
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
3786
3918
|
const maxBytes = ${String(maxBytes)};
|
|
3919
|
+
const editMaxBytes = ${String(WEB_FILE_EDIT_MAX_BYTES)};
|
|
3787
3920
|
const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
|
|
3788
3921
|
|
|
3789
3922
|
function looksBinary(buffer) {
|
|
@@ -3834,11 +3967,18 @@ try {
|
|
|
3834
3967
|
truncated: maxBytes > 0 && size > maxBytes
|
|
3835
3968
|
}));
|
|
3836
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
|
+
}
|
|
3837
3976
|
process.stdout.write(JSON.stringify({
|
|
3838
3977
|
path: realPath,
|
|
3839
3978
|
kind: 'text',
|
|
3840
3979
|
size,
|
|
3841
3980
|
truncated: maxBytes > 0 && size > maxBytes,
|
|
3981
|
+
etag,
|
|
3842
3982
|
content: buffer.toString('utf8')
|
|
3843
3983
|
}));
|
|
3844
3984
|
}
|
|
@@ -3876,12 +4016,17 @@ try {
|
|
|
3876
4016
|
`);
|
|
3877
4017
|
}
|
|
3878
4018
|
|
|
3879
|
-
|
|
4019
|
+
// expectedEtag:读取时拿到的内容 sha256。写入前在容器里重新计算当前内容的哈希,不一致就不写(返回 conflict)。
|
|
4020
|
+
// 检查和写入之间仍有一个极短的窗口(同一个脚本里的两条语句之间),可以接受。
|
|
4021
|
+
function buildContainerFileWriteCommand(requestedPath, content, expectedEtag) {
|
|
3880
4022
|
return buildWebContainerNodeCommand(`
|
|
3881
4023
|
// __MANYOYO_FS_WRITE__
|
|
3882
4024
|
const fs = require('fs');
|
|
4025
|
+
const crypto = require('crypto');
|
|
3883
4026
|
|
|
3884
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))};
|
|
3885
4030
|
const nextContent = ${JSON.stringify(String(content == null ? '' : content))};
|
|
3886
4031
|
|
|
3887
4032
|
try {
|
|
@@ -3891,13 +4036,26 @@ try {
|
|
|
3891
4036
|
throw new Error('目标不是文件: ' + realPath);
|
|
3892
4037
|
}
|
|
3893
4038
|
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
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
|
+
}
|
|
3901
4059
|
} catch (e) {
|
|
3902
4060
|
process.stdout.write(JSON.stringify({
|
|
3903
4061
|
error: e && e.message ? e.message : '保存文件失败'
|
|
@@ -4188,22 +4346,140 @@ async function readJsonBody(req) {
|
|
|
4188
4346
|
}
|
|
4189
4347
|
}
|
|
4190
4348
|
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
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',
|
|
4195
4426
|
...extraHeaders
|
|
4196
|
-
}
|
|
4197
|
-
|
|
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 });
|
|
4198
4469
|
}
|
|
4199
4470
|
|
|
4200
4471
|
function sendNdjson(res, payload) {
|
|
4201
4472
|
res.write(`${JSON.stringify(payload)}\n`);
|
|
4202
4473
|
}
|
|
4203
4474
|
|
|
4475
|
+
const WEB_STREAM_FLUSH_EVENT_TYPES = new Set(['process.exited', 'process.interrupted', 'agent.turn.failed']);
|
|
4476
|
+
|
|
4204
4477
|
function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
|
|
4205
4478
|
return (payload, type, data = {}) => {
|
|
4206
|
-
|
|
4479
|
+
// 流式过程中的事件只改内存(最迟 1 秒落盘);结束类事件立即写盘,顺带写出之前攒着的内容
|
|
4480
|
+
const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data, {
|
|
4481
|
+
defer: !WEB_STREAM_FLUSH_EVENT_TYPES.has(type)
|
|
4482
|
+
});
|
|
4207
4483
|
sendNdjson(res, { ...payload, controlEvent });
|
|
4208
4484
|
};
|
|
4209
4485
|
}
|
|
@@ -4292,13 +4568,8 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
|
|
|
4292
4568
|
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
4293
4569
|
}
|
|
4294
4570
|
|
|
4295
|
-
function sendHtml(res, statusCode, html, extraHeaders = {}) {
|
|
4296
|
-
res
|
|
4297
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
4298
|
-
'Cache-Control': 'no-store',
|
|
4299
|
-
...extraHeaders
|
|
4300
|
-
});
|
|
4301
|
-
res.end(html);
|
|
4571
|
+
function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
|
|
4572
|
+
sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
|
|
4302
4573
|
}
|
|
4303
4574
|
|
|
4304
4575
|
const SETUP_TEST_TIMEOUT_SECONDS = 60;
|
|
@@ -4683,11 +4954,54 @@ function buildSessionAudit(ctx, state, sessionRef) {
|
|
|
4683
4954
|
};
|
|
4684
4955
|
}
|
|
4685
4956
|
|
|
4957
|
+
// 按文件 mtime + size 缓存 index.html 原文(2MB,之前每次请求都同步读一遍)
|
|
4958
|
+
let indexHtmlCache = { key: '', html: '', variants: new Map() };
|
|
4959
|
+
|
|
4686
4960
|
function loadIndexHtml() {
|
|
4687
|
-
|
|
4961
|
+
let stat = null;
|
|
4962
|
+
try {
|
|
4963
|
+
stat = fs.statSync(INDEX_HTML_PATH);
|
|
4964
|
+
} catch (e) {
|
|
4688
4965
|
return '<h1>Template Not Found</h1>';
|
|
4689
4966
|
}
|
|
4690
|
-
|
|
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);
|
|
4691
5005
|
}
|
|
4692
5006
|
|
|
4693
5007
|
function escapeHtmlText(value) {
|
|
@@ -4768,7 +5082,31 @@ function sendTerminalEvent(ws, type, payload = {}) {
|
|
|
4768
5082
|
ws.send(JSON.stringify({ type, ...payload }));
|
|
4769
5083
|
}
|
|
4770
5084
|
|
|
4771
|
-
|
|
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) {
|
|
4772
5110
|
const terminalBootstrap = [
|
|
4773
5111
|
'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
|
|
4774
5112
|
'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
|
|
@@ -4781,6 +5119,10 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4781
5119
|
'if [ -f /run/manyoyo-sys/env.sh ]; then',
|
|
4782
5120
|
' . /run/manyoyo-sys/env.sh',
|
|
4783
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',
|
|
4784
5126
|
'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
|
|
4785
5127
|
' COLUMNS="$MANYOYO_TERM_COLS"',
|
|
4786
5128
|
' LINES="$MANYOYO_TERM_ROWS"',
|
|
@@ -4810,7 +5152,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4810
5152
|
`TERM=${termValue}`,
|
|
4811
5153
|
`COLORTERM=${colorTermValue}`,
|
|
4812
5154
|
`MANYOYO_TERM_COLS=${String(cols)}`,
|
|
4813
|
-
`MANYOYO_TERM_ROWS=${String(rows)}
|
|
5155
|
+
`MANYOYO_TERM_ROWS=${String(rows)}`,
|
|
5156
|
+
`MANYOYO_TERM_ID=${termId}`
|
|
4814
5157
|
],
|
|
4815
5158
|
command: ['/bin/bash', '-lc', terminalBootstrap]
|
|
4816
5159
|
}, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -4818,7 +5161,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4818
5161
|
|
|
4819
5162
|
function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
4820
5163
|
const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
4821
|
-
const
|
|
5164
|
+
const termId = crypto.randomBytes(8).toString('hex');
|
|
5165
|
+
const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows, termId);
|
|
4822
5166
|
const session = {
|
|
4823
5167
|
id: sessionId,
|
|
4824
5168
|
containerName,
|
|
@@ -4861,6 +5205,10 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4861
5205
|
}
|
|
4862
5206
|
session.closing = true;
|
|
4863
5207
|
clearInterval(pingTimer);
|
|
5208
|
+
if (resizeState.timer) {
|
|
5209
|
+
clearTimeout(resizeState.timer);
|
|
5210
|
+
resizeState.timer = null;
|
|
5211
|
+
}
|
|
4864
5212
|
state.terminalSessions.delete(sessionId);
|
|
4865
5213
|
if (ptyProcess && !ptyProcess.killed) {
|
|
4866
5214
|
ptyProcess.kill('SIGTERM');
|
|
@@ -4872,6 +5220,72 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4872
5220
|
}
|
|
4873
5221
|
};
|
|
4874
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
|
+
|
|
4875
5289
|
ptyProcess.stdout.on('data', chunk => {
|
|
4876
5290
|
sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
|
|
4877
5291
|
});
|
|
@@ -4918,7 +5332,7 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4918
5332
|
}
|
|
4919
5333
|
|
|
4920
5334
|
if (payload.type === 'resize') {
|
|
4921
|
-
|
|
5335
|
+
requestTerminalResize(payload.cols, payload.rows);
|
|
4922
5336
|
return;
|
|
4923
5337
|
}
|
|
4924
5338
|
|
|
@@ -4982,7 +5396,7 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
|
4982
5396
|
});
|
|
4983
5397
|
return true;
|
|
4984
5398
|
}
|
|
4985
|
-
|
|
5399
|
+
sendIndexHtml(res, 'login', ctx, { 'Referrer-Policy': 'no-referrer' });
|
|
4986
5400
|
return true;
|
|
4987
5401
|
}
|
|
4988
5402
|
|
|
@@ -5210,8 +5624,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5210
5624
|
sendJson(res, 400, { error: '配置内容不能为空' });
|
|
5211
5625
|
return;
|
|
5212
5626
|
}
|
|
5627
|
+
const ifMatch = req.headers['if-match'];
|
|
5628
|
+
if (ifMatch === undefined) {
|
|
5629
|
+
sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
|
|
5630
|
+
return;
|
|
5631
|
+
}
|
|
5213
5632
|
|
|
5633
|
+
// 从这里到写盘之间不能有 await:读文件、比对版本、写文件在同一个同步段里完成,并发的两个请求不会同时通过检查
|
|
5214
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
|
+
}
|
|
5215
5644
|
let finalRaw = raw;
|
|
5216
5645
|
let parsed = null;
|
|
5217
5646
|
try {
|
|
@@ -5228,6 +5657,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5228
5657
|
sendJson(res, 200, {
|
|
5229
5658
|
saved: true,
|
|
5230
5659
|
path: savePath,
|
|
5660
|
+
etag: computeContentEtag(finalRaw),
|
|
5231
5661
|
defaults: buildConfigDefaults(ctx, parsed)
|
|
5232
5662
|
});
|
|
5233
5663
|
}
|
|
@@ -5548,7 +5978,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5548
5978
|
})
|
|
5549
5979
|
.sort(compareWebSessionCreatedDesc);
|
|
5550
5980
|
|
|
5551
|
-
sendJson(res, 200, { sessions });
|
|
5981
|
+
sendJson(res, 200, { sessions }, {}, { autoEtag: true });
|
|
5552
5982
|
}
|
|
5553
5983
|
},
|
|
5554
5984
|
{
|
|
@@ -5688,14 +6118,62 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5688
6118
|
if (!sessionRef) {
|
|
5689
6119
|
return;
|
|
5690
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
|
+
}
|
|
5691
6127
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5692
6128
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
5693
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);
|
|
5694
6142
|
sendJson(res, 200, {
|
|
5695
6143
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5696
6144
|
containerName: sessionRef.containerName,
|
|
5697
6145
|
agentId: sessionRef.agentId,
|
|
5698
|
-
|
|
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
|
|
5699
6177
|
});
|
|
5700
6178
|
}
|
|
5701
6179
|
},
|
|
@@ -5850,12 +6328,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5850
6328
|
return;
|
|
5851
6329
|
}
|
|
5852
6330
|
|
|
6331
|
+
const ifMatch = req.headers['if-match'];
|
|
6332
|
+
if (ifMatch === undefined) {
|
|
6333
|
+
sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
|
|
6334
|
+
return;
|
|
6335
|
+
}
|
|
6336
|
+
|
|
5853
6337
|
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
5854
6338
|
const result = await execJsonCommandInWebContainer(
|
|
5855
6339
|
ctx,
|
|
5856
6340
|
sessionRef.containerName,
|
|
5857
|
-
buildContainerFileWriteCommand(targetPath, content)
|
|
6341
|
+
buildContainerFileWriteCommand(targetPath, content, normalizeIfMatchHeader(ifMatch))
|
|
5858
6342
|
);
|
|
6343
|
+
if (result && result.conflict === true) {
|
|
6344
|
+
sendJson(res, 409, { error: result.error || '文件已在容器里被修改', conflict: true });
|
|
6345
|
+
return;
|
|
6346
|
+
}
|
|
5859
6347
|
if (result && result.error) {
|
|
5860
6348
|
sendJson(res, 400, { error: result.error });
|
|
5861
6349
|
return;
|
|
@@ -5930,7 +6418,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5930
6418
|
|
|
5931
6419
|
const containerMap = listWebManyoyoContainers(ctx);
|
|
5932
6420
|
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
5933
|
-
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
|
|
6421
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
|
|
5934
6422
|
}
|
|
5935
6423
|
},
|
|
5936
6424
|
{
|
|
@@ -5950,7 +6438,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5950
6438
|
containerInfo.defaultCommand
|
|
5951
6439
|
);
|
|
5952
6440
|
const agentProgram = resolveAgentProgram(effectiveTemplate);
|
|
5953
|
-
const models = await
|
|
6441
|
+
const models = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
|
|
5954
6442
|
sendJson(res, 200, {
|
|
5955
6443
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5956
6444
|
agentProgram,
|
|
@@ -5958,6 +6446,34 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5958
6446
|
});
|
|
5959
6447
|
}
|
|
5960
6448
|
},
|
|
6449
|
+
{
|
|
6450
|
+
// 毫秒级:只读宿主机上的 env 文件,不进容器;慢的 CLI 目录探测走上面的 /models
|
|
6451
|
+
method: 'GET',
|
|
6452
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/model-options$/),
|
|
6453
|
+
handler: async match => {
|
|
6454
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
6455
|
+
if (!sessionRef) {
|
|
6456
|
+
return;
|
|
6457
|
+
}
|
|
6458
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
6459
|
+
const containerInfo = containerMap[sessionRef.containerName] || {};
|
|
6460
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
6461
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
|
|
6462
|
+
history,
|
|
6463
|
+
sessionRef.agentId,
|
|
6464
|
+
containerInfo.defaultCommand
|
|
6465
|
+
));
|
|
6466
|
+
let env = {};
|
|
6467
|
+
try {
|
|
6468
|
+
env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
6469
|
+
} catch (e) { /* 读不到 env 就只给空选项 */ }
|
|
6470
|
+
sendJson(res, 200, {
|
|
6471
|
+
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
6472
|
+
agentProgram,
|
|
6473
|
+
...buildEnvModelOptions(agentProgram, env)
|
|
6474
|
+
});
|
|
6475
|
+
}
|
|
6476
|
+
},
|
|
5961
6477
|
{
|
|
5962
6478
|
method: 'POST',
|
|
5963
6479
|
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/model$/),
|
|
@@ -6063,9 +6579,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6063
6579
|
sendJson(res, 400, { error: 'command 不能为空' });
|
|
6064
6580
|
return;
|
|
6065
6581
|
}
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
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
|
+
}
|
|
6069
6599
|
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
|
|
6070
6600
|
// role 用 'system'、带 mode: 'command',与前端实时发送时的约定对齐(workspace-panel.tsx
|
|
6071
6601
|
// handleSend 的 command 分支)——命令输出本来就不该走 assistant 的气泡样式和 markdown 渲染,
|
|
@@ -6095,11 +6625,20 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6095
6625
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
6096
6626
|
return;
|
|
6097
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
|
+
}
|
|
6098
6636
|
|
|
6099
6637
|
let prepared = null;
|
|
6100
6638
|
try {
|
|
6101
6639
|
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
6102
6640
|
} catch (e) {
|
|
6641
|
+
releaseAgentClaim();
|
|
6103
6642
|
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
6104
6643
|
return;
|
|
6105
6644
|
}
|
|
@@ -6107,8 +6646,10 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6107
6646
|
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
|
|
6108
6647
|
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
6109
6648
|
mode: 'agent',
|
|
6110
|
-
contextMode
|
|
6649
|
+
contextMode,
|
|
6650
|
+
...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
|
|
6111
6651
|
});
|
|
6652
|
+
releaseAgentClaim();
|
|
6112
6653
|
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
|
|
6113
6654
|
agentProgram: agentMeta.agentProgram
|
|
6114
6655
|
});
|
|
@@ -6144,6 +6685,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6144
6685
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
6145
6686
|
return;
|
|
6146
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
|
+
}
|
|
6147
6695
|
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
6148
6696
|
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
6149
6697
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
@@ -6154,7 +6702,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6154
6702
|
|
|
6155
6703
|
const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
6156
6704
|
mode: 'agent',
|
|
6157
|
-
pending: true
|
|
6705
|
+
pending: true,
|
|
6706
|
+
...(streamClientId.value ? { clientMessageId: streamClientId.value } : {})
|
|
6158
6707
|
});
|
|
6159
6708
|
const traceMessage = appendWebAgentTraceMessage(
|
|
6160
6709
|
state.webHistoryDir,
|
|
@@ -6282,7 +6831,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6282
6831
|
content: traceLines.join('\n'),
|
|
6283
6832
|
traceEvents: traceEvents.slice(),
|
|
6284
6833
|
pending: true
|
|
6285
|
-
});
|
|
6834
|
+
}, { defer: true });
|
|
6286
6835
|
}
|
|
6287
6836
|
if (event && event.type === 'content_delta' && typeof event.content === 'string') {
|
|
6288
6837
|
const now = Date.now();
|
|
@@ -6308,7 +6857,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6308
6857
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
6309
6858
|
content: event.content,
|
|
6310
6859
|
pending: true
|
|
6311
|
-
});
|
|
6860
|
+
}, { defer: true });
|
|
6312
6861
|
lastPartialPersistAt = now;
|
|
6313
6862
|
}
|
|
6314
6863
|
}
|
|
@@ -6658,7 +7207,7 @@ async function startWebServer(options) {
|
|
|
6658
7207
|
|
|
6659
7208
|
// 前端构建产物见 frontend/(npm run build:web),输出到 lib/web/index.html
|
|
6660
7209
|
if (req.method === 'GET' && pathname === '/') {
|
|
6661
|
-
|
|
7210
|
+
sendIndexHtml(res, 'app', ctx);
|
|
6662
7211
|
return;
|
|
6663
7212
|
}
|
|
6664
7213
|
|
|
@@ -6834,6 +7383,7 @@ async function startWebServer(options) {
|
|
|
6834
7383
|
port: listenPort,
|
|
6835
7384
|
close: () => new Promise(resolve => {
|
|
6836
7385
|
ctx.logger.info('web server closing');
|
|
7386
|
+
flushWebSessionHistories();
|
|
6837
7387
|
if (state.networkWatch) {
|
|
6838
7388
|
state.networkWatch.stop();
|
|
6839
7389
|
state.networkWatch = null;
|
|
@@ -6872,5 +7422,15 @@ async function startWebServer(options) {
|
|
|
6872
7422
|
}
|
|
6873
7423
|
|
|
6874
7424
|
module.exports = {
|
|
6875
|
-
startWebServer
|
|
7425
|
+
startWebServer,
|
|
7426
|
+
buildEnvModelOptions,
|
|
7427
|
+
// 仅供测试:直接驱动历史读写层
|
|
7428
|
+
__testing: {
|
|
7429
|
+
appendWebSessionMessage,
|
|
7430
|
+
appendWebSessionControlEvent,
|
|
7431
|
+
removeWebSessionMessage,
|
|
7432
|
+
loadWebSessionHistory,
|
|
7433
|
+
saveWebSessionHistory,
|
|
7434
|
+
cloneWebAgentSessionsDeep
|
|
7435
|
+
}
|
|
6876
7436
|
};
|