@xcanwin/manyoyo 7.0.18 → 7.0.20
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/docker/res/claude/settings.json +0 -5
- package/lib/core/event-store.js +5 -36
- package/lib/core/events.js +24 -48
- package/lib/web/frontend/app.js +32 -0
- package/lib/web/frontend/shadcn.html +26 -26
- package/lib/web/server.js +150 -107
- package/package.json +1 -1
package/lib/web/server.js
CHANGED
|
@@ -138,6 +138,11 @@ const WEB_SESSION_KEY_SEPARATOR = '~';
|
|
|
138
138
|
const WEB_DEFAULT_AGENT_ID = 'default';
|
|
139
139
|
const WEB_DEFAULT_AGENT_NAME = 'AGENT 1';
|
|
140
140
|
const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
|
|
141
|
+
// 取 15s 是为了在 nginx proxy_read_timeout 默认 60s 下留足余量(即便某次心跳
|
|
142
|
+
// 因事件循环繁忙被推迟,也还有三次机会),同时不至于把 NDJSON 流刷得太碎
|
|
143
|
+
const DEFAULT_AGENT_STREAM_HEARTBEAT_MS = 15 * 1000;
|
|
144
|
+
// token 级增量的历史落盘节流:写盘只为断线/刷新后能对账恢复,不需要逐 token 持久化
|
|
145
|
+
const AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS = 1000;
|
|
141
146
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
142
147
|
const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
143
148
|
const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
@@ -462,9 +467,10 @@ function removeContainerIdempotent(ctx, containerName) {
|
|
|
462
467
|
}
|
|
463
468
|
}
|
|
464
469
|
|
|
465
|
-
function removeAllAgentHistoryArtifacts(
|
|
470
|
+
function removeAllAgentHistoryArtifacts(webHistoryDir, containerName, agentIds) {
|
|
471
|
+
const eventStore = new FileEventStore(webHistoryDir);
|
|
466
472
|
(Array.isArray(agentIds) ? agentIds : []).forEach(agentId => {
|
|
467
|
-
|
|
473
|
+
eventStore.remove(buildWebSessionKey(containerName, agentId));
|
|
468
474
|
});
|
|
469
475
|
}
|
|
470
476
|
|
|
@@ -622,57 +628,29 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
|
|
|
622
628
|
return message;
|
|
623
629
|
}
|
|
624
630
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
const LIVE_HISTORY_FLUSH_INTERVAL_MS = 400;
|
|
631
|
-
|
|
632
|
-
// 中间态历史落盘节流:同一 aggregateId 在节流窗口内只允许成功落盘一次,供
|
|
633
|
-
// appendWebSessionControlEvent(events 镜像)与 /agent/stream 路由里的
|
|
634
|
-
// patchWebSessionMessage(trace/content_delta 消息内容)共用同一个"落盘配额",
|
|
635
|
-
// 避免同一个事件被两条独立写路径各自整份重写一次 demo.json。节流只影响"实时
|
|
636
|
-
// 进度对轮询标签页可见的时机",不影响最终数据——两条写路径在回合结束/出错时
|
|
637
|
-
// 都还有一次不受节流影响的完整落盘。
|
|
638
|
-
function shouldFlushLiveHistory(state, aggregateId) {
|
|
639
|
-
const now = Date.now();
|
|
640
|
-
const lastFlushAt = state._liveHistoryFlushThrottle.get(aggregateId) || 0;
|
|
641
|
-
if (now - lastFlushAt < LIVE_HISTORY_FLUSH_INTERVAL_MS) {
|
|
642
|
-
return false;
|
|
643
|
-
}
|
|
644
|
-
state._liveHistoryFlushThrottle.set(aggregateId, now);
|
|
645
|
-
return true;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function appendWebSessionControlEvent(state, sessionRef, type, data = {}) {
|
|
649
|
-
const aggregateId = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
650
|
-
// seq 必须以 state.eventStore 的内存缓存为准(而不是下面 agentSession.events
|
|
651
|
-
// 这份可能因落盘节流而滞后的历史 JSON 副本),否则节流生效时算出来的 seq
|
|
652
|
-
// 会与 eventStore 实际的 lastSeq 对不上,append() 会因"seq 必须连续递增"报错。
|
|
631
|
+
function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}) {
|
|
632
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
633
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
634
|
+
const events = Array.isArray(agentSession.events) ? agentSession.events : [];
|
|
635
|
+
const lastEvent = events.length ? events[events.length - 1] : null;
|
|
653
636
|
const event = createControlEvent({
|
|
654
637
|
type,
|
|
655
|
-
aggregateId,
|
|
656
|
-
seq:
|
|
638
|
+
aggregateId: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
639
|
+
seq: lastEvent ? lastEvent.seq + 1 : 1,
|
|
657
640
|
data
|
|
658
641
|
});
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
662
|
-
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
663
|
-
const events = Array.isArray(agentSession.events) ? agentSession.events : [];
|
|
642
|
+
const eventStore = new FileEventStore(webHistoryDir);
|
|
643
|
+
eventStore.append(event);
|
|
664
644
|
agentSession.events = [...events, event].slice(-WEB_HISTORY_MAX_MESSAGES);
|
|
665
645
|
agentSession.updatedAt = event.timestamp;
|
|
666
646
|
history.updatedAt = event.timestamp;
|
|
667
|
-
|
|
668
|
-
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
669
|
-
}
|
|
647
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
670
648
|
return event;
|
|
671
649
|
}
|
|
672
650
|
|
|
673
|
-
function loadWebSessionControlEvents(
|
|
651
|
+
function loadWebSessionControlEvents(webHistoryDir, sessionRef, fallbackEvents = []) {
|
|
674
652
|
try {
|
|
675
|
-
const events =
|
|
653
|
+
const events = new FileEventStore(webHistoryDir).read(
|
|
676
654
|
buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
|
|
677
655
|
);
|
|
678
656
|
if (events.length) {
|
|
@@ -957,7 +935,12 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
957
935
|
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
958
936
|
const flagSpecs = [
|
|
959
937
|
{ flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
|
|
960
|
-
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
|
|
938
|
+
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
|
|
939
|
+
// 没有这个 flag 时 claude 只在整条 assistant 消息生成完之后才吐一行 JSON:
|
|
940
|
+
// 一段几千字的回答会静默几十秒再一次性刷出来,用户看到的"执行过程"是分批跳变的。
|
|
941
|
+
// 带上之后会额外输出 stream_event/content_block_delta 的 token 级增量
|
|
942
|
+
//(解析见 extractContentDeltaFromPayload)
|
|
943
|
+
{ flag: '--include-partial-messages', pattern: /(?:^|\s)--include-partial-messages(?:\s|$)/ }
|
|
961
944
|
];
|
|
962
945
|
const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
|
|
963
946
|
if (sessionId) {
|
|
@@ -1819,12 +1802,39 @@ function prepareStructuredTraceEvents(agentProgram, payload, state) {
|
|
|
1819
1802
|
return [];
|
|
1820
1803
|
}
|
|
1821
1804
|
|
|
1822
|
-
function extractContentDeltaFromPayload(agentProgram, payload) {
|
|
1805
|
+
function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
|
|
1823
1806
|
if (!payload || typeof payload !== 'object') {
|
|
1824
1807
|
return null;
|
|
1825
1808
|
}
|
|
1826
1809
|
if (agentProgram === 'claude') {
|
|
1827
|
-
|
|
1810
|
+
const payloadType = pickFirstString(payload.type);
|
|
1811
|
+
// --include-partial-messages 产生的 token 级增量。text 是累计全文(服务端
|
|
1812
|
+
// 落盘用),chunk 是本次新增的片段(下发给前端用)——一轮回答可能有几千个
|
|
1813
|
+
// 增量,每次都把累计全文发一遍是 O(n²) 流量,长回答能到 MB 级。
|
|
1814
|
+
// 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
|
|
1815
|
+
// 语义与下面整条 assistant 消息的 reset 一致
|
|
1816
|
+
if (payloadType === 'stream_event') {
|
|
1817
|
+
const streamEvent = toPlainObject(payload.event);
|
|
1818
|
+
const streamEventType = pickFirstString(streamEvent.type);
|
|
1819
|
+
if (streamEventType === 'message_start') {
|
|
1820
|
+
state.claudePartialText = '';
|
|
1821
|
+
return { text: '', reset: true, partial: true, chunk: '', chunkReset: true };
|
|
1822
|
+
}
|
|
1823
|
+
if (streamEventType !== 'content_block_delta') {
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
const delta = toPlainObject(streamEvent.delta);
|
|
1827
|
+
if (pickFirstString(delta.type) !== 'text_delta') {
|
|
1828
|
+
return null;
|
|
1829
|
+
}
|
|
1830
|
+
const deltaText = typeof delta.text === 'string' ? delta.text : '';
|
|
1831
|
+
if (!deltaText) {
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
state.claudePartialText = `${state.claudePartialText || ''}${deltaText}`;
|
|
1835
|
+
return { text: state.claudePartialText, reset: true, partial: true, chunk: deltaText };
|
|
1836
|
+
}
|
|
1837
|
+
if (payloadType !== 'assistant') {
|
|
1828
1838
|
return null;
|
|
1829
1839
|
}
|
|
1830
1840
|
const message = toPlainObject(payload.message);
|
|
@@ -2096,7 +2106,7 @@ function prepareCodexTraceEvent(payload) {
|
|
|
2096
2106
|
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
2097
2107
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2098
2108
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
2099
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
2109
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
2100
2110
|
const containerInfo = containerMap[sessionRef.containerName] || {};
|
|
2101
2111
|
const normalizedContainerTemplate = normalizeAgentPromptCommandTemplate(history.agentPromptCommand, 'agentPromptCommand');
|
|
2102
2112
|
if (normalizedContainerTemplate !== history.agentPromptCommand) {
|
|
@@ -3036,7 +3046,7 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3036
3046
|
}
|
|
3037
3047
|
|
|
3038
3048
|
function resolveUniqueContainerName(ctx, state, baseName) {
|
|
3039
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
3049
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3040
3050
|
const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
3041
3051
|
const taken = new Set([...Object.keys(containerMap), ...historyNames]);
|
|
3042
3052
|
// 基于历史最大编号 +1 命名,即使较小编号的副本被删除也不回收其编号,
|
|
@@ -3117,7 +3127,7 @@ async function createClonedContainer(ctx, state, sourceContainerName, requestedN
|
|
|
3117
3127
|
let finalName = '';
|
|
3118
3128
|
if (requestedName) {
|
|
3119
3129
|
validateContainerNameStrict(requestedName);
|
|
3120
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
3130
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3121
3131
|
const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
3122
3132
|
const taken = new Set([...Object.keys(containerMap), ...historyNames]);
|
|
3123
3133
|
if (taken.has(requestedName)) {
|
|
@@ -3181,20 +3191,7 @@ function estimateStartTimeFromStatus(status) {
|
|
|
3181
3191
|
return null;
|
|
3182
3192
|
}
|
|
3183
3193
|
|
|
3184
|
-
|
|
3185
|
-
// 容器一多时 docker ps + 批量 inspect 本身就不便宜,多标签页轮询会让它们在
|
|
3186
|
-
// 短时间内反复触发。2 秒内的重复调用直接复用上一次结果,不再次触碰
|
|
3187
|
-
// ctx.dockerExecArgs(同步 spawnSync,会独占事件循环)。
|
|
3188
|
-
const CONTAINER_LIST_CACHE_TTL_MS = 2000;
|
|
3189
|
-
|
|
3190
|
-
function listWebManyoyoContainers(ctx, state) {
|
|
3191
|
-
if (state && state._containerListCache) {
|
|
3192
|
-
const { timestamp, data } = state._containerListCache;
|
|
3193
|
-
if (Date.now() - timestamp < CONTAINER_LIST_CACHE_TTL_MS) {
|
|
3194
|
-
return data;
|
|
3195
|
-
}
|
|
3196
|
-
}
|
|
3197
|
-
|
|
3194
|
+
function listWebManyoyoContainers(ctx) {
|
|
3198
3195
|
const output = ctx.dockerExecArgs(
|
|
3199
3196
|
['ps', '-a', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
|
|
3200
3197
|
{ ignoreError: true }
|
|
@@ -3202,9 +3199,6 @@ function listWebManyoyoContainers(ctx, state) {
|
|
|
3202
3199
|
|
|
3203
3200
|
const map = {};
|
|
3204
3201
|
if (!output.trim()) {
|
|
3205
|
-
if (state) {
|
|
3206
|
-
state._containerListCache = { timestamp: Date.now(), data: map };
|
|
3207
|
-
}
|
|
3208
3202
|
return map;
|
|
3209
3203
|
}
|
|
3210
3204
|
|
|
@@ -3261,9 +3255,6 @@ function listWebManyoyoContainers(ctx, state) {
|
|
|
3261
3255
|
};
|
|
3262
3256
|
});
|
|
3263
3257
|
|
|
3264
|
-
if (state) {
|
|
3265
|
-
state._containerListCache = { timestamp: Date.now(), data: map };
|
|
3266
|
-
}
|
|
3267
3258
|
return map;
|
|
3268
3259
|
}
|
|
3269
3260
|
|
|
@@ -3798,7 +3789,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3798
3789
|
}
|
|
3799
3790
|
onEvent({
|
|
3800
3791
|
type: 'content_delta',
|
|
3801
|
-
content: contentDeltaAccumulator
|
|
3792
|
+
content: contentDeltaAccumulator,
|
|
3793
|
+
// token 级增量标记为 partial:调用方据此跳过事件日志、
|
|
3794
|
+
// 并对历史落盘做节流(否则每个 token 都要重写一次历史 JSON);
|
|
3795
|
+
// chunk/chunkReset 只在 partial 时有意义,见 extractContentDeltaFromPayload
|
|
3796
|
+
partial: deltaContent.partial === true,
|
|
3797
|
+
chunk: typeof deltaContent.chunk === 'string' ? deltaContent.chunk : '',
|
|
3798
|
+
chunkReset: deltaContent.chunkReset === true
|
|
3802
3799
|
});
|
|
3803
3800
|
}
|
|
3804
3801
|
return;
|
|
@@ -3920,9 +3917,9 @@ function sendNdjson(res, payload) {
|
|
|
3920
3917
|
res.write(`${JSON.stringify(payload)}\n`);
|
|
3921
3918
|
}
|
|
3922
3919
|
|
|
3923
|
-
function createWebStreamEmitter(res,
|
|
3920
|
+
function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
|
|
3924
3921
|
return (payload, type, data = {}) => {
|
|
3925
|
-
const controlEvent = appendWebSessionControlEvent(
|
|
3922
|
+
const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data);
|
|
3926
3923
|
sendNdjson(res, { ...payload, controlEvent });
|
|
3927
3924
|
};
|
|
3928
3925
|
}
|
|
@@ -4016,10 +4013,15 @@ function getValidSessionRef(ctx, res, encodedName) {
|
|
|
4016
4013
|
return parsed;
|
|
4017
4014
|
}
|
|
4018
4015
|
|
|
4019
|
-
|
|
4016
|
+
// preloadedHistory:调用方已经读过同一个容器的历史时直接复用。历史 JSON 是整份
|
|
4017
|
+
// 读文件 + JSON.parse,容器多、agent 多时按 agent 数重复读会把 GET /api/sessions
|
|
4018
|
+
// 拖到秒级,而它是同步 IO——事件循环被占住期间 agent/stream 的输出只能攒着分批推
|
|
4019
|
+
function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHistory = null) {
|
|
4020
4020
|
const containerName = sessionRef && sessionRef.containerName ? sessionRef.containerName : '';
|
|
4021
4021
|
const agentId = sessionRef && sessionRef.agentId ? sessionRef.agentId : WEB_DEFAULT_AGENT_ID;
|
|
4022
|
-
const history =
|
|
4022
|
+
const history = preloadedHistory && typeof preloadedHistory === 'object'
|
|
4023
|
+
? preloadedHistory
|
|
4024
|
+
: loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
4023
4025
|
const realAgentSession = getWebAgentSession(history, agentId, { includeArchived: true });
|
|
4024
4026
|
const agentSession = realAgentSession
|
|
4025
4027
|
|| (agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
|
|
@@ -4116,7 +4118,7 @@ function buildSessionDetail(ctx, state, containerMap, name) {
|
|
|
4116
4118
|
sessionRef.agentId,
|
|
4117
4119
|
containerInfo.defaultCommand
|
|
4118
4120
|
);
|
|
4119
|
-
const summary = buildSessionSummary(ctx, state, containerMap, sessionRef);
|
|
4121
|
+
const summary = buildSessionSummary(ctx, state, containerMap, sessionRef, history);
|
|
4120
4122
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
4121
4123
|
|| (sessionRef.agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
|
|
4122
4124
|
const latestMessage = agentSession && agentSession.messages.length
|
|
@@ -4159,7 +4161,7 @@ function buildSessionAudit(ctx, state, sessionRef) {
|
|
|
4159
4161
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
4160
4162
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
4161
4163
|
|| createEmptyWebAgentSession(sessionRef.agentId);
|
|
4162
|
-
const events = loadWebSessionControlEvents(state, sessionRef, agentSession.events);
|
|
4164
|
+
const events = loadWebSessionControlEvents(state.webHistoryDir, sessionRef, agentSession.events);
|
|
4163
4165
|
const applied = history.applied && typeof history.applied === 'object' && !Array.isArray(history.applied)
|
|
4164
4166
|
? history.applied
|
|
4165
4167
|
: {};
|
|
@@ -4827,7 +4829,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4827
4829
|
method: 'GET',
|
|
4828
4830
|
match: currentPath => currentPath === '/api/system/capacity' ? [] : null,
|
|
4829
4831
|
handler: async () => {
|
|
4830
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
4832
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
4831
4833
|
const diskPath = path.dirname(path.resolve(state.webConfigPath));
|
|
4832
4834
|
const report = estimateContainerCapacity({
|
|
4833
4835
|
runtimeCommand: ctx.dockerCmd || 'docker',
|
|
@@ -4904,7 +4906,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4904
4906
|
method: 'GET',
|
|
4905
4907
|
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
4906
4908
|
handler: async () => {
|
|
4907
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
4909
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
4908
4910
|
const names = new Set([
|
|
4909
4911
|
...Object.keys(containerMap),
|
|
4910
4912
|
...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
|
|
@@ -4917,7 +4919,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4917
4919
|
.map(agentSession => buildSessionSummary(ctx, state, containerMap, {
|
|
4918
4920
|
containerName: name,
|
|
4919
4921
|
agentId: agentSession.agentId
|
|
4920
|
-
}))
|
|
4922
|
+
}, history))
|
|
4921
4923
|
.filter(Boolean);
|
|
4922
4924
|
})
|
|
4923
4925
|
.sort(compareWebSessionCreatedDesc);
|
|
@@ -5302,7 +5304,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5302
5304
|
return;
|
|
5303
5305
|
}
|
|
5304
5306
|
|
|
5305
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
5307
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
5306
5308
|
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
5307
5309
|
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
|
|
5308
5310
|
}
|
|
@@ -5315,7 +5317,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5315
5317
|
if (!sessionRef) {
|
|
5316
5318
|
return;
|
|
5317
5319
|
}
|
|
5318
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
5320
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
5319
5321
|
const containerInfo = containerMap[sessionRef.containerName] || {};
|
|
5320
5322
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5321
5323
|
const effectiveTemplate = resolveEffectiveAgentPromptCommandForSession(
|
|
@@ -5413,7 +5415,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5413
5415
|
return;
|
|
5414
5416
|
}
|
|
5415
5417
|
|
|
5416
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
5418
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
5417
5419
|
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
5418
5420
|
sendJson(res, 200, {
|
|
5419
5421
|
saved: true,
|
|
@@ -5562,7 +5564,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5562
5564
|
'Cache-Control': 'no-store',
|
|
5563
5565
|
'X-Accel-Buffering': 'no'
|
|
5564
5566
|
});
|
|
5565
|
-
|
|
5567
|
+
// agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
|
|
5568
|
+
// 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
|
|
5569
|
+
// 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
|
|
5570
|
+
// 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
|
|
5571
|
+
const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
|
|
5572
|
+
let lastStreamWriteAt = Date.now();
|
|
5573
|
+
const heartbeatTimer = setInterval(() => {
|
|
5574
|
+
if (res.writableEnded || res.destroyed) {
|
|
5575
|
+
return;
|
|
5576
|
+
}
|
|
5577
|
+
if (Date.now() - lastStreamWriteAt < heartbeatMs) {
|
|
5578
|
+
return;
|
|
5579
|
+
}
|
|
5580
|
+
sendNdjson(res, { type: 'ping' });
|
|
5581
|
+
lastStreamWriteAt = Date.now();
|
|
5582
|
+
}, Math.max(50, Math.floor(heartbeatMs / 2)));
|
|
5583
|
+
if (typeof heartbeatTimer.unref === 'function') {
|
|
5584
|
+
heartbeatTimer.unref();
|
|
5585
|
+
}
|
|
5586
|
+
const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
|
|
5587
|
+
const emitStreamEvent = (...args) => {
|
|
5588
|
+
lastStreamWriteAt = Date.now();
|
|
5589
|
+
return rawEmitStreamEvent(...args);
|
|
5590
|
+
};
|
|
5566
5591
|
emitStreamEvent({
|
|
5567
5592
|
type: 'meta',
|
|
5568
5593
|
containerName: sessionRef.containerName,
|
|
@@ -5591,6 +5616,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5591
5616
|
pending: true
|
|
5592
5617
|
});
|
|
5593
5618
|
|
|
5619
|
+
let lastPartialPersistAt = 0;
|
|
5594
5620
|
const diagStreamStartedAt = Date.now(); // DIAG_LOG
|
|
5595
5621
|
let diagEventSeq = 0; // DIAG_LOG
|
|
5596
5622
|
diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
|
|
@@ -5602,29 +5628,35 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5602
5628
|
const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
5603
5629
|
agentProgram: agentMeta.agentProgram,
|
|
5604
5630
|
onEvent: event => {
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5631
|
+
// token 级增量:只走网线,不进事件日志、不逐条打诊断日志,
|
|
5632
|
+
// 历史落盘按 AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
|
|
5633
|
+
const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
|
|
5634
|
+
if (!isPartialDelta) {
|
|
5635
|
+
diagEventSeq += 1; // DIAG_LOG
|
|
5636
|
+
diagLog(ctx, 'stream_event', { // DIAG_LOG
|
|
5637
|
+
session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5638
|
+
seq: diagEventSeq,
|
|
5639
|
+
type: event && event.type,
|
|
5640
|
+
sinceStartMs: Date.now() - diagStreamStartedAt,
|
|
5641
|
+
textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
|
|
5642
|
+
contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
|
|
5643
|
+
});
|
|
5644
|
+
}
|
|
5614
5645
|
if (event && event.type === 'trace' && event.text) {
|
|
5615
5646
|
traceLines.push(String(event.text));
|
|
5616
5647
|
if (event.traceEvent && typeof event.traceEvent === 'object') {
|
|
5617
5648
|
traceEvents.push(event.traceEvent);
|
|
5618
5649
|
}
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
});
|
|
5625
|
-
}
|
|
5650
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5651
|
+
content: traceLines.join('\n'),
|
|
5652
|
+
traceEvents: traceEvents.slice(),
|
|
5653
|
+
pending: true
|
|
5654
|
+
});
|
|
5626
5655
|
}
|
|
5627
5656
|
if (event && event.type === 'content_delta' && typeof event.content === 'string') {
|
|
5657
|
+
const now = Date.now();
|
|
5658
|
+
const shouldPersist = !isPartialDelta
|
|
5659
|
+
|| now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
|
|
5628
5660
|
if (!streamingReplyMessageId) {
|
|
5629
5661
|
const streamingReplyMessage = appendWebSessionMessage(
|
|
5630
5662
|
state.webHistoryDir,
|
|
@@ -5640,13 +5672,24 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5640
5672
|
streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
|
|
5641
5673
|
? streamingReplyMessage.id
|
|
5642
5674
|
: '';
|
|
5643
|
-
|
|
5675
|
+
lastPartialPersistAt = now;
|
|
5676
|
+
} else if (shouldPersist) {
|
|
5644
5677
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
5645
5678
|
content: event.content,
|
|
5646
5679
|
pending: true
|
|
5647
5680
|
});
|
|
5681
|
+
lastPartialPersistAt = now;
|
|
5648
5682
|
}
|
|
5649
5683
|
}
|
|
5684
|
+
if (isPartialDelta) {
|
|
5685
|
+
lastStreamWriteAt = Date.now();
|
|
5686
|
+
// 只发增量片段:前端在 content_chunk 上做追加,
|
|
5687
|
+
// reset 表示换了一条 assistant 消息、从空白重新开始
|
|
5688
|
+
sendNdjson(res, event.chunkReset === true
|
|
5689
|
+
? { type: 'content_chunk', text: '', reset: true }
|
|
5690
|
+
: { type: 'content_chunk', text: event.chunk });
|
|
5691
|
+
return;
|
|
5692
|
+
}
|
|
5650
5693
|
emitStreamEvent(event, resolveWebStreamControlEventType(event), {
|
|
5651
5694
|
transportType: event && event.type ? event.type : '',
|
|
5652
5695
|
text: event && (event.text || event.content) ? String(event.text || event.content) : ''
|
|
@@ -5723,6 +5766,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5723
5766
|
error: e && e.message ? e.message : 'Agent 执行失败'
|
|
5724
5767
|
}, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
|
|
5725
5768
|
} finally {
|
|
5769
|
+
clearInterval(heartbeatTimer);
|
|
5726
5770
|
res.end();
|
|
5727
5771
|
}
|
|
5728
5772
|
}
|
|
@@ -5743,7 +5787,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5743
5787
|
sendJson(res, 404, { error: '当前会话没有运行中的 agent 任务' });
|
|
5744
5788
|
return;
|
|
5745
5789
|
}
|
|
5746
|
-
appendWebSessionControlEvent(state, sessionRef, 'session.stopping');
|
|
5790
|
+
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopping');
|
|
5747
5791
|
sendJson(res, 200, { ok: true, stopping: true });
|
|
5748
5792
|
}
|
|
5749
5793
|
},
|
|
@@ -5762,7 +5806,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5762
5806
|
|
|
5763
5807
|
if (removeHistory) {
|
|
5764
5808
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5765
|
-
removeAllAgentHistoryArtifacts(state, sessionRef.containerName, Object.keys(history.agents || {}));
|
|
5809
|
+
removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, Object.keys(history.agents || {}));
|
|
5766
5810
|
removeWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5767
5811
|
} else if (removedContainer) {
|
|
5768
5812
|
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'system', `容器 ${sessionRef.containerName} 已删除。`);
|
|
@@ -5791,7 +5835,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5791
5835
|
if (history.agents && typeof history.agents === 'object') {
|
|
5792
5836
|
if (removeHistory) {
|
|
5793
5837
|
delete history.agents[sessionRef.agentId];
|
|
5794
|
-
removeAllAgentHistoryArtifacts(state, sessionRef.containerName, [sessionRef.agentId]);
|
|
5838
|
+
removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, [sessionRef.agentId]);
|
|
5795
5839
|
} else if (history.agents[sessionRef.agentId]) {
|
|
5796
5840
|
history.agents[sessionRef.agentId].archived = true;
|
|
5797
5841
|
history.agents[sessionRef.agentId].updatedAt = new Date().toISOString();
|
|
@@ -5861,6 +5905,10 @@ async function startWebServer(options) {
|
|
|
5861
5905
|
showImagePullHint: options.showImagePullHint,
|
|
5862
5906
|
removeContainer: options.removeContainer,
|
|
5863
5907
|
logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
|
|
5908
|
+
// agent/stream 的保活心跳间隔,需小于反向代理的空闲读超时(nginx 默认 60s)
|
|
5909
|
+
agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
|
|
5910
|
+
? options.agentStreamHeartbeatMs
|
|
5911
|
+
: DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
|
|
5864
5912
|
colors: options.colors || {
|
|
5865
5913
|
GREEN: '',
|
|
5866
5914
|
CYAN: '',
|
|
@@ -5903,11 +5951,6 @@ async function startWebServer(options) {
|
|
|
5903
5951
|
terminalSessions: new Map(),
|
|
5904
5952
|
agentRuns: new Map()
|
|
5905
5953
|
};
|
|
5906
|
-
// 跨请求复用同一个 FileEventStore 实例,append() 的增量缓存才能生效
|
|
5907
|
-
// (否则每次都 new 一个新实例,缓存永远命中不到)
|
|
5908
|
-
state.eventStore = new FileEventStore(state.webHistoryDir);
|
|
5909
|
-
// shouldFlushLiveHistory() 的节流状态:aggregateId -> 上次成功落盘的时间戳
|
|
5910
|
-
state._liveHistoryFlushThrottle = new Map();
|
|
5911
5954
|
|
|
5912
5955
|
ensureWebHistoryDir(state.webHistoryDir);
|
|
5913
5956
|
|