@xcanwin/manyoyo 7.0.17 → 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/lib/web/frontend/app.js +32 -0
- package/lib/web/frontend/shadcn.html +75 -75
- package/lib/web/server.js +115 -19
- 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_.-]*)$/;
|
|
@@ -930,7 +935,12 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
930
935
|
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
931
936
|
const flagSpecs = [
|
|
932
937
|
{ flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
|
|
933
|
-
{ 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|$)/ }
|
|
934
944
|
];
|
|
935
945
|
const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
|
|
936
946
|
if (sessionId) {
|
|
@@ -1792,12 +1802,39 @@ function prepareStructuredTraceEvents(agentProgram, payload, state) {
|
|
|
1792
1802
|
return [];
|
|
1793
1803
|
}
|
|
1794
1804
|
|
|
1795
|
-
function extractContentDeltaFromPayload(agentProgram, payload) {
|
|
1805
|
+
function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
|
|
1796
1806
|
if (!payload || typeof payload !== 'object') {
|
|
1797
1807
|
return null;
|
|
1798
1808
|
}
|
|
1799
1809
|
if (agentProgram === 'claude') {
|
|
1800
|
-
|
|
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') {
|
|
1801
1838
|
return null;
|
|
1802
1839
|
}
|
|
1803
1840
|
const message = toPlainObject(payload.message);
|
|
@@ -3752,7 +3789,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3752
3789
|
}
|
|
3753
3790
|
onEvent({
|
|
3754
3791
|
type: 'content_delta',
|
|
3755
|
-
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
|
|
3756
3799
|
});
|
|
3757
3800
|
}
|
|
3758
3801
|
return;
|
|
@@ -3970,10 +4013,15 @@ function getValidSessionRef(ctx, res, encodedName) {
|
|
|
3970
4013
|
return parsed;
|
|
3971
4014
|
}
|
|
3972
4015
|
|
|
3973
|
-
|
|
4016
|
+
// preloadedHistory:调用方已经读过同一个容器的历史时直接复用。历史 JSON 是整份
|
|
4017
|
+
// 读文件 + JSON.parse,容器多、agent 多时按 agent 数重复读会把 GET /api/sessions
|
|
4018
|
+
// 拖到秒级,而它是同步 IO——事件循环被占住期间 agent/stream 的输出只能攒着分批推
|
|
4019
|
+
function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHistory = null) {
|
|
3974
4020
|
const containerName = sessionRef && sessionRef.containerName ? sessionRef.containerName : '';
|
|
3975
4021
|
const agentId = sessionRef && sessionRef.agentId ? sessionRef.agentId : WEB_DEFAULT_AGENT_ID;
|
|
3976
|
-
const history =
|
|
4022
|
+
const history = preloadedHistory && typeof preloadedHistory === 'object'
|
|
4023
|
+
? preloadedHistory
|
|
4024
|
+
: loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
3977
4025
|
const realAgentSession = getWebAgentSession(history, agentId, { includeArchived: true });
|
|
3978
4026
|
const agentSession = realAgentSession
|
|
3979
4027
|
|| (agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
|
|
@@ -4070,7 +4118,7 @@ function buildSessionDetail(ctx, state, containerMap, name) {
|
|
|
4070
4118
|
sessionRef.agentId,
|
|
4071
4119
|
containerInfo.defaultCommand
|
|
4072
4120
|
);
|
|
4073
|
-
const summary = buildSessionSummary(ctx, state, containerMap, sessionRef);
|
|
4121
|
+
const summary = buildSessionSummary(ctx, state, containerMap, sessionRef, history);
|
|
4074
4122
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
4075
4123
|
|| (sessionRef.agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
|
|
4076
4124
|
const latestMessage = agentSession && agentSession.messages.length
|
|
@@ -4871,7 +4919,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4871
4919
|
.map(agentSession => buildSessionSummary(ctx, state, containerMap, {
|
|
4872
4920
|
containerName: name,
|
|
4873
4921
|
agentId: agentSession.agentId
|
|
4874
|
-
}))
|
|
4922
|
+
}, history))
|
|
4875
4923
|
.filter(Boolean);
|
|
4876
4924
|
})
|
|
4877
4925
|
.sort(compareWebSessionCreatedDesc);
|
|
@@ -5516,7 +5564,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5516
5564
|
'Cache-Control': 'no-store',
|
|
5517
5565
|
'X-Accel-Buffering': 'no'
|
|
5518
5566
|
});
|
|
5519
|
-
|
|
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
|
+
};
|
|
5520
5591
|
emitStreamEvent({
|
|
5521
5592
|
type: 'meta',
|
|
5522
5593
|
containerName: sessionRef.containerName,
|
|
@@ -5545,6 +5616,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5545
5616
|
pending: true
|
|
5546
5617
|
});
|
|
5547
5618
|
|
|
5619
|
+
let lastPartialPersistAt = 0;
|
|
5548
5620
|
const diagStreamStartedAt = Date.now(); // DIAG_LOG
|
|
5549
5621
|
let diagEventSeq = 0; // DIAG_LOG
|
|
5550
5622
|
diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
|
|
@@ -5556,15 +5628,20 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5556
5628
|
const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
5557
5629
|
agentProgram: agentMeta.agentProgram,
|
|
5558
5630
|
onEvent: event => {
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
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
|
+
}
|
|
5568
5645
|
if (event && event.type === 'trace' && event.text) {
|
|
5569
5646
|
traceLines.push(String(event.text));
|
|
5570
5647
|
if (event.traceEvent && typeof event.traceEvent === 'object') {
|
|
@@ -5577,6 +5654,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5577
5654
|
});
|
|
5578
5655
|
}
|
|
5579
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;
|
|
5580
5660
|
if (!streamingReplyMessageId) {
|
|
5581
5661
|
const streamingReplyMessage = appendWebSessionMessage(
|
|
5582
5662
|
state.webHistoryDir,
|
|
@@ -5592,13 +5672,24 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5592
5672
|
streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
|
|
5593
5673
|
? streamingReplyMessage.id
|
|
5594
5674
|
: '';
|
|
5595
|
-
|
|
5675
|
+
lastPartialPersistAt = now;
|
|
5676
|
+
} else if (shouldPersist) {
|
|
5596
5677
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
5597
5678
|
content: event.content,
|
|
5598
5679
|
pending: true
|
|
5599
5680
|
});
|
|
5681
|
+
lastPartialPersistAt = now;
|
|
5600
5682
|
}
|
|
5601
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
|
+
}
|
|
5602
5693
|
emitStreamEvent(event, resolveWebStreamControlEventType(event), {
|
|
5603
5694
|
transportType: event && event.type ? event.type : '',
|
|
5604
5695
|
text: event && (event.text || event.content) ? String(event.text || event.content) : ''
|
|
@@ -5675,6 +5766,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5675
5766
|
error: e && e.message ? e.message : 'Agent 执行失败'
|
|
5676
5767
|
}, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
|
|
5677
5768
|
} finally {
|
|
5769
|
+
clearInterval(heartbeatTimer);
|
|
5678
5770
|
res.end();
|
|
5679
5771
|
}
|
|
5680
5772
|
}
|
|
@@ -5813,6 +5905,10 @@ async function startWebServer(options) {
|
|
|
5813
5905
|
showImagePullHint: options.showImagePullHint,
|
|
5814
5906
|
removeContainer: options.removeContainer,
|
|
5815
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,
|
|
5816
5912
|
colors: options.colors || {
|
|
5817
5913
|
GREEN: '',
|
|
5818
5914
|
CYAN: '',
|