@xcanwin/manyoyo 7.0.18 → 7.0.21
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 +59 -59
- package/lib/web/server.js +236 -394
- package/package.json +1 -1
package/lib/web/server.js
CHANGED
|
@@ -30,82 +30,7 @@ const {
|
|
|
30
30
|
projectSessionEvents
|
|
31
31
|
} = require('../core/events');
|
|
32
32
|
const { FileEventStore } = require('../core/event-store');
|
|
33
|
-
const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path'); // DIAG_LOG
|
|
34
33
|
|
|
35
|
-
// DIAG_LOG BEGIN:临时详细诊断日志基础设施,用于排查 serve 接口超时 / agent
|
|
36
|
-
// 流式回复长时间卡在"…"不实时更新的问题。排查结束后可整体删除:全局搜索
|
|
37
|
-
// "DIAG_LOG" 能定位到全部相关代码(这段辅助函数、各处打点调用、下方
|
|
38
|
-
// /api/diag/logs 接口、/logs 页面路由),一并删掉不影响其它功能。
|
|
39
|
-
const DIAG_LOG_LINE_PATTERN = /^\[([^\]]+)\] \[pid:(\d+)\] \[(\w+)\] \[DIAG_LOG\] (\S+)(?: ([\s\S]*))?$/;
|
|
40
|
-
|
|
41
|
-
function diagLog(ctx, category, extra = {}) {
|
|
42
|
-
if (!ctx || !ctx.logger || typeof ctx.logger.info !== 'function') {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
ctx.logger.info(`[DIAG_LOG] ${String(category || '')}`, extra);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function parseDiagLogLine(line) {
|
|
49
|
-
const matched = String(line || '').match(DIAG_LOG_LINE_PATTERN);
|
|
50
|
-
if (!matched) {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
let extra = {};
|
|
54
|
-
if (matched[5]) {
|
|
55
|
-
try {
|
|
56
|
-
extra = JSON.parse(matched[5]);
|
|
57
|
-
} catch (e) {
|
|
58
|
-
extra = { raw: matched[5] };
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
ts: matched[1],
|
|
63
|
-
pid: Number(matched[2]),
|
|
64
|
-
level: matched[3],
|
|
65
|
-
category: matched[4],
|
|
66
|
-
extra
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function diagLogDir(ctx) {
|
|
71
|
-
if (ctx && ctx.logger && typeof ctx.logger.path === 'string' && ctx.logger.path) {
|
|
72
|
-
return path.dirname(ctx.logger.path);
|
|
73
|
-
}
|
|
74
|
-
return buildManyoyoLogPath('serve').dir;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function readDiagLogEntries(ctx, options = {}) {
|
|
78
|
-
const dateTag = options.date ? String(options.date) : getLocalDateTag();
|
|
79
|
-
const filePath = path.join(diagLogDir(ctx), `serve-${dateTag}.log`);
|
|
80
|
-
if (!fs.existsSync(filePath)) {
|
|
81
|
-
return [];
|
|
82
|
-
}
|
|
83
|
-
const limit = Number.isInteger(options.limit) && options.limit > 0 ? Math.min(options.limit, 2000) : 500;
|
|
84
|
-
const category = options.category ? String(options.category) : '';
|
|
85
|
-
const session = options.session ? String(options.session) : '';
|
|
86
|
-
const keyword = options.keyword ? String(options.keyword).toLowerCase() : '';
|
|
87
|
-
|
|
88
|
-
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
89
|
-
const entries = [];
|
|
90
|
-
for (const line of lines) {
|
|
91
|
-
const entry = parseDiagLogLine(line);
|
|
92
|
-
if (!entry) {
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
if (category && entry.category !== category) {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (session && entry.extra.session !== session) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (keyword && !line.toLowerCase().includes(keyword)) {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
entries.push(entry);
|
|
105
|
-
}
|
|
106
|
-
return entries.slice(-limit);
|
|
107
|
-
}
|
|
108
|
-
// DIAG_LOG END(下方还有零散打点调用与 /api/diag/logs、/logs 路由,同样标记为 DIAG_LOG)
|
|
109
34
|
|
|
110
35
|
const WEB_HISTORY_MAX_MESSAGES = 500;
|
|
111
36
|
const WEB_OUTPUT_MAX_CHARS = 16000;
|
|
@@ -138,6 +63,11 @@ const WEB_SESSION_KEY_SEPARATOR = '~';
|
|
|
138
63
|
const WEB_DEFAULT_AGENT_ID = 'default';
|
|
139
64
|
const WEB_DEFAULT_AGENT_NAME = 'AGENT 1';
|
|
140
65
|
const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
|
|
66
|
+
// 取 15s 是为了在 nginx proxy_read_timeout 默认 60s 下留足余量(即便某次心跳
|
|
67
|
+
// 因事件循环繁忙被推迟,也还有三次机会),同时不至于把 NDJSON 流刷得太碎
|
|
68
|
+
const DEFAULT_AGENT_STREAM_HEARTBEAT_MS = 15 * 1000;
|
|
69
|
+
// token 级增量的历史落盘节流:写盘只为断线/刷新后能对账恢复,不需要逐 token 持久化
|
|
70
|
+
const AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS = 1000;
|
|
141
71
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
142
72
|
const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
143
73
|
const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
@@ -462,9 +392,10 @@ function removeContainerIdempotent(ctx, containerName) {
|
|
|
462
392
|
}
|
|
463
393
|
}
|
|
464
394
|
|
|
465
|
-
function removeAllAgentHistoryArtifacts(
|
|
395
|
+
function removeAllAgentHistoryArtifacts(webHistoryDir, containerName, agentIds) {
|
|
396
|
+
const eventStore = new FileEventStore(webHistoryDir);
|
|
466
397
|
(Array.isArray(agentIds) ? agentIds : []).forEach(agentId => {
|
|
467
|
-
|
|
398
|
+
eventStore.remove(buildWebSessionKey(containerName, agentId));
|
|
468
399
|
});
|
|
469
400
|
}
|
|
470
401
|
|
|
@@ -622,57 +553,29 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
|
|
|
622
553
|
return message;
|
|
623
554
|
}
|
|
624
555
|
|
|
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 必须连续递增"报错。
|
|
556
|
+
function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}) {
|
|
557
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
558
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
559
|
+
const events = Array.isArray(agentSession.events) ? agentSession.events : [];
|
|
560
|
+
const lastEvent = events.length ? events[events.length - 1] : null;
|
|
653
561
|
const event = createControlEvent({
|
|
654
562
|
type,
|
|
655
|
-
aggregateId,
|
|
656
|
-
seq:
|
|
563
|
+
aggregateId: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
564
|
+
seq: lastEvent ? lastEvent.seq + 1 : 1,
|
|
657
565
|
data
|
|
658
566
|
});
|
|
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 : [];
|
|
567
|
+
const eventStore = new FileEventStore(webHistoryDir);
|
|
568
|
+
eventStore.append(event);
|
|
664
569
|
agentSession.events = [...events, event].slice(-WEB_HISTORY_MAX_MESSAGES);
|
|
665
570
|
agentSession.updatedAt = event.timestamp;
|
|
666
571
|
history.updatedAt = event.timestamp;
|
|
667
|
-
|
|
668
|
-
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
669
|
-
}
|
|
572
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
670
573
|
return event;
|
|
671
574
|
}
|
|
672
575
|
|
|
673
|
-
function loadWebSessionControlEvents(
|
|
576
|
+
function loadWebSessionControlEvents(webHistoryDir, sessionRef, fallbackEvents = []) {
|
|
674
577
|
try {
|
|
675
|
-
const events =
|
|
578
|
+
const events = new FileEventStore(webHistoryDir).read(
|
|
676
579
|
buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
|
|
677
580
|
);
|
|
678
581
|
if (events.length) {
|
|
@@ -957,7 +860,12 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
957
860
|
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
958
861
|
const flagSpecs = [
|
|
959
862
|
{ flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
|
|
960
|
-
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
|
|
863
|
+
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
|
|
864
|
+
// 没有这个 flag 时 claude 只在整条 assistant 消息生成完之后才吐一行 JSON:
|
|
865
|
+
// 一段几千字的回答会静默几十秒再一次性刷出来,用户看到的"执行过程"是分批跳变的。
|
|
866
|
+
// 带上之后会额外输出 stream_event/content_block_delta 的 token 级增量
|
|
867
|
+
//(解析见 extractContentDeltaFromPayload)
|
|
868
|
+
{ flag: '--include-partial-messages', pattern: /(?:^|\s)--include-partial-messages(?:\s|$)/ }
|
|
961
869
|
];
|
|
962
870
|
const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
|
|
963
871
|
if (sessionId) {
|
|
@@ -1819,12 +1727,39 @@ function prepareStructuredTraceEvents(agentProgram, payload, state) {
|
|
|
1819
1727
|
return [];
|
|
1820
1728
|
}
|
|
1821
1729
|
|
|
1822
|
-
function extractContentDeltaFromPayload(agentProgram, payload) {
|
|
1730
|
+
function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
|
|
1823
1731
|
if (!payload || typeof payload !== 'object') {
|
|
1824
1732
|
return null;
|
|
1825
1733
|
}
|
|
1826
1734
|
if (agentProgram === 'claude') {
|
|
1827
|
-
|
|
1735
|
+
const payloadType = pickFirstString(payload.type);
|
|
1736
|
+
// --include-partial-messages 产生的 token 级增量。text 是累计全文(服务端
|
|
1737
|
+
// 落盘用),chunk 是本次新增的片段(下发给前端用)——一轮回答可能有几千个
|
|
1738
|
+
// 增量,每次都把累计全文发一遍是 O(n²) 流量,长回答能到 MB 级。
|
|
1739
|
+
// 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
|
|
1740
|
+
// 语义与下面整条 assistant 消息的 reset 一致
|
|
1741
|
+
if (payloadType === 'stream_event') {
|
|
1742
|
+
const streamEvent = toPlainObject(payload.event);
|
|
1743
|
+
const streamEventType = pickFirstString(streamEvent.type);
|
|
1744
|
+
if (streamEventType === 'message_start') {
|
|
1745
|
+
state.claudePartialText = '';
|
|
1746
|
+
return { text: '', reset: true, partial: true, chunk: '', chunkReset: true };
|
|
1747
|
+
}
|
|
1748
|
+
if (streamEventType !== 'content_block_delta') {
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1751
|
+
const delta = toPlainObject(streamEvent.delta);
|
|
1752
|
+
if (pickFirstString(delta.type) !== 'text_delta') {
|
|
1753
|
+
return null;
|
|
1754
|
+
}
|
|
1755
|
+
const deltaText = typeof delta.text === 'string' ? delta.text : '';
|
|
1756
|
+
if (!deltaText) {
|
|
1757
|
+
return null;
|
|
1758
|
+
}
|
|
1759
|
+
state.claudePartialText = `${state.claudePartialText || ''}${deltaText}`;
|
|
1760
|
+
return { text: state.claudePartialText, reset: true, partial: true, chunk: deltaText };
|
|
1761
|
+
}
|
|
1762
|
+
if (payloadType !== 'assistant') {
|
|
1828
1763
|
return null;
|
|
1829
1764
|
}
|
|
1830
1765
|
const message = toPlainObject(payload.message);
|
|
@@ -2096,7 +2031,7 @@ function prepareCodexTraceEvent(payload) {
|
|
|
2096
2031
|
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
2097
2032
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2098
2033
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
2099
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
2034
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
2100
2035
|
const containerInfo = containerMap[sessionRef.containerName] || {};
|
|
2101
2036
|
const normalizedContainerTemplate = normalizeAgentPromptCommandTemplate(history.agentPromptCommand, 'agentPromptCommand');
|
|
2102
2037
|
if (normalizedContainerTemplate !== history.agentPromptCommand) {
|
|
@@ -2745,11 +2680,26 @@ function parseAndValidateConfigRaw(raw) {
|
|
|
2745
2680
|
return config;
|
|
2746
2681
|
}
|
|
2747
2682
|
|
|
2683
|
+
// 新建容器对话框会拿 defaults.hostPath 做预填,但 validateHostPath 明确拒绝
|
|
2684
|
+
// 根目录 / /home / $HOME。serve 以 $HOME 启动(root 用户下就是 /root)时,
|
|
2685
|
+
// 预填的值必定创建失败——这里直接留空,逼用户用"选择"选一个真实工作目录
|
|
2686
|
+
function sanitizeDefaultHostPath(hostPath) {
|
|
2687
|
+
const value = String(hostPath || '').trim();
|
|
2688
|
+
if (!value) {
|
|
2689
|
+
return '';
|
|
2690
|
+
}
|
|
2691
|
+
const homeDir = process.env.HOME || os.homedir() || '/home';
|
|
2692
|
+
if (value === '/' || value === '/home' || value === homeDir) {
|
|
2693
|
+
return '';
|
|
2694
|
+
}
|
|
2695
|
+
return value;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2748
2698
|
function buildConfigDefaults(ctx, config) {
|
|
2749
2699
|
const parsed = toPlainObject(config);
|
|
2750
2700
|
const defaults = {
|
|
2751
2701
|
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
2752
|
-
hostPath: pickFirstString(parsed.hostPath, ctx.hostPath),
|
|
2702
|
+
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath, ctx.hostPath)),
|
|
2753
2703
|
containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
|
|
2754
2704
|
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
2755
2705
|
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
@@ -3036,7 +2986,7 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3036
2986
|
}
|
|
3037
2987
|
|
|
3038
2988
|
function resolveUniqueContainerName(ctx, state, baseName) {
|
|
3039
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
2989
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3040
2990
|
const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
3041
2991
|
const taken = new Set([...Object.keys(containerMap), ...historyNames]);
|
|
3042
2992
|
// 基于历史最大编号 +1 命名,即使较小编号的副本被删除也不回收其编号,
|
|
@@ -3117,7 +3067,7 @@ async function createClonedContainer(ctx, state, sourceContainerName, requestedN
|
|
|
3117
3067
|
let finalName = '';
|
|
3118
3068
|
if (requestedName) {
|
|
3119
3069
|
validateContainerNameStrict(requestedName);
|
|
3120
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
3070
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3121
3071
|
const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
3122
3072
|
const taken = new Set([...Object.keys(containerMap), ...historyNames]);
|
|
3123
3073
|
if (taken.has(requestedName)) {
|
|
@@ -3181,20 +3131,7 @@ function estimateStartTimeFromStatus(status) {
|
|
|
3181
3131
|
return null;
|
|
3182
3132
|
}
|
|
3183
3133
|
|
|
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
|
-
|
|
3134
|
+
function listWebManyoyoContainers(ctx) {
|
|
3198
3135
|
const output = ctx.dockerExecArgs(
|
|
3199
3136
|
['ps', '-a', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
|
|
3200
3137
|
{ ignoreError: true }
|
|
@@ -3202,9 +3139,6 @@ function listWebManyoyoContainers(ctx, state) {
|
|
|
3202
3139
|
|
|
3203
3140
|
const map = {};
|
|
3204
3141
|
if (!output.trim()) {
|
|
3205
|
-
if (state) {
|
|
3206
|
-
state._containerListCache = { timestamp: Date.now(), data: map };
|
|
3207
|
-
}
|
|
3208
3142
|
return map;
|
|
3209
3143
|
}
|
|
3210
3144
|
|
|
@@ -3222,8 +3156,11 @@ function listWebManyoyoContainers(ctx, state) {
|
|
|
3222
3156
|
});
|
|
3223
3157
|
|
|
3224
3158
|
// 逐容器单独 docker inspect 是 N+1 同步阻塞调用(spawnSync 无 timeout,会
|
|
3225
|
-
// 独占 Node
|
|
3226
|
-
//
|
|
3159
|
+
// 独占 Node 单线程事件循环),这里合并成一次批量 inspect。
|
|
3160
|
+
// 注意:31 个容器实测 `ps -a` 约 40ms、批量 `inspect` 约 45ms,容器运行时调用
|
|
3161
|
+
// 并不是 GET /api/sessions 慢的原因——真正的开销在逐容器同步读取并解析整份
|
|
3162
|
+
// 历史 JSON(见 buildSessionSummary 的 preloadedHistory 注释)。排查这条路径
|
|
3163
|
+
// 变慢时别先怀疑 docker/podman。
|
|
3227
3164
|
const defaultCommandByName = {};
|
|
3228
3165
|
if (candidates.length) {
|
|
3229
3166
|
let inspectOutput = '';
|
|
@@ -3261,9 +3198,6 @@ function listWebManyoyoContainers(ctx, state) {
|
|
|
3261
3198
|
};
|
|
3262
3199
|
});
|
|
3263
3200
|
|
|
3264
|
-
if (state) {
|
|
3265
|
-
state._containerListCache = { timestamp: Date.now(), data: map };
|
|
3266
|
-
}
|
|
3267
3201
|
return map;
|
|
3268
3202
|
}
|
|
3269
3203
|
|
|
@@ -3721,24 +3655,38 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3721
3655
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
3722
3656
|
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
3723
3657
|
: sessionRefOrContainerName;
|
|
3724
|
-
const sessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
3725
3658
|
const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
|
|
3726
3659
|
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3660
|
+
// 调用方可能已经在路由入口占过锁(见 reserveWebAgentRun):复用那一份 runState,
|
|
3661
|
+
// 不要另起一个对象,否则 stopWebAgentRun 拿到的还是占位、杀不到真正的进程
|
|
3662
|
+
const runState = opts.runState && typeof opts.runState === 'object'
|
|
3663
|
+
? opts.runState
|
|
3664
|
+
: reserveWebAgentRun(state, sessionRef);
|
|
3665
|
+
let process;
|
|
3666
|
+
try {
|
|
3667
|
+
process = spawn(
|
|
3668
|
+
ctx.dockerCmd,
|
|
3669
|
+
['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
|
|
3670
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
3671
|
+
);
|
|
3672
|
+
} catch (e) {
|
|
3673
|
+
// spawn 同步抛错(如 dockerCmd 不存在)时必须释放锁,否则这个容器永远发不出消息
|
|
3674
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
3675
|
+
throw e;
|
|
3676
|
+
}
|
|
3732
3677
|
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
process,
|
|
3737
|
-
command,
|
|
3738
|
-
startedAt: new Date().toISOString(),
|
|
3739
|
-
stopping: false
|
|
3740
|
-
};
|
|
3678
|
+
runState.process = process;
|
|
3679
|
+
runState.command = command;
|
|
3680
|
+
runState.startedAt = new Date().toISOString();
|
|
3741
3681
|
state.agentRuns.set(sessionRef.containerName, runState);
|
|
3682
|
+
// 准备阶段(拉起容器等)用户就点了停止:进程刚 spawn 出来立刻收掉
|
|
3683
|
+
if (runState.stopping === true) {
|
|
3684
|
+
try {
|
|
3685
|
+
process.kill('SIGTERM');
|
|
3686
|
+
} catch (e) {
|
|
3687
|
+
// 进程可能已经退出,忽略
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3742
3690
|
|
|
3743
3691
|
return await new Promise((resolve, reject) => {
|
|
3744
3692
|
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
@@ -3798,7 +3746,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3798
3746
|
}
|
|
3799
3747
|
onEvent({
|
|
3800
3748
|
type: 'content_delta',
|
|
3801
|
-
content: contentDeltaAccumulator
|
|
3749
|
+
content: contentDeltaAccumulator,
|
|
3750
|
+
// token 级增量标记为 partial:调用方据此跳过事件日志、
|
|
3751
|
+
// 并对历史落盘做节流(否则每个 token 都要重写一次历史 JSON);
|
|
3752
|
+
// chunk/chunkReset 只在 partial 时有意义,见 extractContentDeltaFromPayload
|
|
3753
|
+
partial: deltaContent.partial === true,
|
|
3754
|
+
chunk: typeof deltaContent.chunk === 'string' ? deltaContent.chunk : '',
|
|
3755
|
+
chunkReset: deltaContent.chunkReset === true
|
|
3802
3756
|
});
|
|
3803
3757
|
}
|
|
3804
3758
|
return;
|
|
@@ -3850,11 +3804,11 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3850
3804
|
});
|
|
3851
3805
|
|
|
3852
3806
|
process.on('error', error => {
|
|
3853
|
-
state
|
|
3807
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
3854
3808
|
reject(error);
|
|
3855
3809
|
});
|
|
3856
3810
|
process.on('close', code => {
|
|
3857
|
-
state
|
|
3811
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
3858
3812
|
if (stdoutPending) {
|
|
3859
3813
|
emitStdoutTraceLine(stdoutPending);
|
|
3860
3814
|
stdoutPending = '';
|
|
@@ -3920,9 +3874,9 @@ function sendNdjson(res, payload) {
|
|
|
3920
3874
|
res.write(`${JSON.stringify(payload)}\n`);
|
|
3921
3875
|
}
|
|
3922
3876
|
|
|
3923
|
-
function createWebStreamEmitter(res,
|
|
3877
|
+
function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
|
|
3924
3878
|
return (payload, type, data = {}) => {
|
|
3925
|
-
const controlEvent = appendWebSessionControlEvent(
|
|
3879
|
+
const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data);
|
|
3926
3880
|
sendNdjson(res, { ...payload, controlEvent });
|
|
3927
3881
|
};
|
|
3928
3882
|
}
|
|
@@ -3937,9 +3891,43 @@ function resolveWebStreamControlEventType(event) {
|
|
|
3937
3891
|
return 'process.stdout';
|
|
3938
3892
|
}
|
|
3939
3893
|
|
|
3894
|
+
// 容器级运行锁的"占位"。必须在路由里同步调用:从 409 检查到真正 spawn 之间隔着
|
|
3895
|
+
// readJsonBody / prepareWebAgentExecution(含 ensureWebContainer 拉起容器)等多个
|
|
3896
|
+
// await,容器冷启动时这个窗口有好几秒。不先占住的话第二次发送会穿过 409 检查,
|
|
3897
|
+
// 同一容器里真的跑起两个 agent 进程,而且后者会覆盖前者的运行登记,
|
|
3898
|
+
// 让第一个进程再也停不掉
|
|
3899
|
+
function reserveWebAgentRun(state, sessionRef) {
|
|
3900
|
+
const runState = {
|
|
3901
|
+
containerName: sessionRef.containerName,
|
|
3902
|
+
sessionKey: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
3903
|
+
process: null,
|
|
3904
|
+
command: '',
|
|
3905
|
+
startedAt: new Date().toISOString(),
|
|
3906
|
+
stopping: false
|
|
3907
|
+
};
|
|
3908
|
+
state.agentRuns.set(sessionRef.containerName, runState);
|
|
3909
|
+
return runState;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
// 只有占位仍是自己那一份时才释放,避免误删别人的登记
|
|
3913
|
+
function releaseWebAgentRun(state, containerName, runState) {
|
|
3914
|
+
if (state.agentRuns.get(containerName) === runState) {
|
|
3915
|
+
state.agentRuns.delete(containerName);
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3940
3919
|
function stopWebAgentRun(state, containerName) {
|
|
3941
3920
|
const runState = state.agentRuns.get(containerName);
|
|
3942
|
-
if (!runState
|
|
3921
|
+
if (!runState) {
|
|
3922
|
+
return false;
|
|
3923
|
+
}
|
|
3924
|
+
if (!runState.process) {
|
|
3925
|
+
// 还在准备阶段(拉起容器 / 探测 resume),进程尚未 spawn:先记下停止意图,
|
|
3926
|
+
// execAgentInWebContainerStream 拿到进程后会立刻收掉它
|
|
3927
|
+
runState.stopping = true;
|
|
3928
|
+
return true;
|
|
3929
|
+
}
|
|
3930
|
+
if (runState.process.killed) {
|
|
3943
3931
|
return false;
|
|
3944
3932
|
}
|
|
3945
3933
|
runState.stopping = true;
|
|
@@ -4016,10 +4004,15 @@ function getValidSessionRef(ctx, res, encodedName) {
|
|
|
4016
4004
|
return parsed;
|
|
4017
4005
|
}
|
|
4018
4006
|
|
|
4019
|
-
|
|
4007
|
+
// preloadedHistory:调用方已经读过同一个容器的历史时直接复用。历史 JSON 是整份
|
|
4008
|
+
// 读文件 + JSON.parse,容器多、agent 多时按 agent 数重复读会把 GET /api/sessions
|
|
4009
|
+
// 拖到秒级,而它是同步 IO——事件循环被占住期间 agent/stream 的输出只能攒着分批推
|
|
4010
|
+
function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHistory = null) {
|
|
4020
4011
|
const containerName = sessionRef && sessionRef.containerName ? sessionRef.containerName : '';
|
|
4021
4012
|
const agentId = sessionRef && sessionRef.agentId ? sessionRef.agentId : WEB_DEFAULT_AGENT_ID;
|
|
4022
|
-
const history =
|
|
4013
|
+
const history = preloadedHistory && typeof preloadedHistory === 'object'
|
|
4014
|
+
? preloadedHistory
|
|
4015
|
+
: loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
4023
4016
|
const realAgentSession = getWebAgentSession(history, agentId, { includeArchived: true });
|
|
4024
4017
|
const agentSession = realAgentSession
|
|
4025
4018
|
|| (agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
|
|
@@ -4039,6 +4032,7 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
|
|
|
4039
4032
|
status: containerInfo.status || 'history',
|
|
4040
4033
|
defaultCommand: containerInfo.defaultCommand || ''
|
|
4041
4034
|
});
|
|
4035
|
+
const agentRunState = state.agentRuns ? state.agentRuns.get(containerName) : undefined;
|
|
4042
4036
|
const createdAt = agentSession.createdAt || containerInfo.createdAt || null;
|
|
4043
4037
|
const updatedAt = agentSession.updatedAt
|
|
4044
4038
|
|| (latestMessage && latestMessage.timestamp)
|
|
@@ -4065,6 +4059,11 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
|
|
|
4065
4059
|
hostPath: applied.hostPath || '',
|
|
4066
4060
|
containerPath: applied.containerPath || '',
|
|
4067
4061
|
archived: agentSession.archived === true,
|
|
4062
|
+
// 运行锁是容器级的,界面却是 agent 级的:把"容器在忙"和"是不是自己在忙"
|
|
4063
|
+
// 都透出去,同容器的其他 AGENT 才能提前禁用输入,而不是点了发送才吃 409
|
|
4064
|
+
containerBusy: agentRunState !== undefined,
|
|
4065
|
+
agentRunning: agentRunState !== undefined
|
|
4066
|
+
&& agentRunState.sessionKey === buildWebSessionKey(containerName, agentId),
|
|
4068
4067
|
...(synthetic ? { synthetic: true } : {})
|
|
4069
4068
|
};
|
|
4070
4069
|
}
|
|
@@ -4116,7 +4115,7 @@ function buildSessionDetail(ctx, state, containerMap, name) {
|
|
|
4116
4115
|
sessionRef.agentId,
|
|
4117
4116
|
containerInfo.defaultCommand
|
|
4118
4117
|
);
|
|
4119
|
-
const summary = buildSessionSummary(ctx, state, containerMap, sessionRef);
|
|
4118
|
+
const summary = buildSessionSummary(ctx, state, containerMap, sessionRef, history);
|
|
4120
4119
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
4121
4120
|
|| (sessionRef.agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
|
|
4122
4121
|
const latestMessage = agentSession && agentSession.messages.length
|
|
@@ -4159,7 +4158,7 @@ function buildSessionAudit(ctx, state, sessionRef) {
|
|
|
4159
4158
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
4160
4159
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
|
|
4161
4160
|
|| createEmptyWebAgentSession(sessionRef.agentId);
|
|
4162
|
-
const events = loadWebSessionControlEvents(state, sessionRef, agentSession.events);
|
|
4161
|
+
const events = loadWebSessionControlEvents(state.webHistoryDir, sessionRef, agentSession.events);
|
|
4163
4162
|
const applied = history.applied && typeof history.applied === 'object' && !Array.isArray(history.applied)
|
|
4164
4163
|
? history.applied
|
|
4165
4164
|
: {};
|
|
@@ -4290,123 +4289,6 @@ function renderLoginHtml(ctx) {
|
|
|
4290
4289
|
return applyServeTitle(loadTemplate('login.html'), ctx);
|
|
4291
4290
|
}
|
|
4292
4291
|
|
|
4293
|
-
// DIAG_LOG:临时诊断日志查看页,自成一个内联 HTML 字符串(不接入 frontend-shadcn
|
|
4294
|
-
// 构建流程),配合 /api/diag/logs 接口使用。排查结束后可整体删除本函数及其
|
|
4295
|
-
// 调用点(全局搜索 "DIAG_LOG")。
|
|
4296
|
-
function renderDiagLogsHtml() {
|
|
4297
|
-
return `<!DOCTYPE html>
|
|
4298
|
-
<html lang="zh-CN">
|
|
4299
|
-
<head>
|
|
4300
|
-
<meta charset="UTF-8">
|
|
4301
|
-
<title>MANYOYO 诊断日志</title>
|
|
4302
|
-
<style>
|
|
4303
|
-
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #d7dae0; }
|
|
4304
|
-
header { padding: 12px 16px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; border-bottom: 1px solid #262b36; position: sticky; top: 0; background: #0b0d12; }
|
|
4305
|
-
header input, header select { background: #171a21; border: 1px solid #2c313d; color: #d7dae0; padding: 5px 8px; border-radius: 4px; font-size: 13px; }
|
|
4306
|
-
header button { background: #2b6cb0; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
|
4307
|
-
header button.secondary { background: #2c313d; }
|
|
4308
|
-
header label { font-size: 12px; display: flex; align-items: center; gap: 4px; }
|
|
4309
|
-
main { padding: 8px 16px 40px; }
|
|
4310
|
-
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
4311
|
-
th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #1c202a; vertical-align: top; white-space: pre-wrap; word-break: break-all; }
|
|
4312
|
-
th { position: sticky; top: 53px; background: #0b0d12; color: #8b93a3; font-weight: 600; }
|
|
4313
|
-
tr:hover { background: #12151c; }
|
|
4314
|
-
.cat { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #1e2530; color: #8fd3ff; }
|
|
4315
|
-
.status-ok { color: #7ee787; }
|
|
4316
|
-
.status-err { color: #ff7b72; }
|
|
4317
|
-
.dur-slow { color: #ffa657; font-weight: 600; }
|
|
4318
|
-
#count { color: #8b93a3; font-size: 12px; }
|
|
4319
|
-
</style>
|
|
4320
|
-
</head>
|
|
4321
|
-
<body>
|
|
4322
|
-
<header>
|
|
4323
|
-
<strong>诊断日志</strong>
|
|
4324
|
-
<label>日期 <input id="f-date" type="text" placeholder="YYYY-MM-DD"></label>
|
|
4325
|
-
<label>分类 <input id="f-category" type="text" placeholder="http_request / docker_exec / stream_event / stream_exec_start / stream_exec_end"></label>
|
|
4326
|
-
<label>会话 <input id="f-session" type="text" placeholder="containerName 或 containerName~agentId"></label>
|
|
4327
|
-
<label>关键字 <input id="f-keyword" type="text" placeholder="任意文本"></label>
|
|
4328
|
-
<label>条数 <input id="f-limit" type="text" value="500" style="width:60px"></label>
|
|
4329
|
-
<button id="btn-refresh">刷新</button>
|
|
4330
|
-
<label><input id="f-auto" type="checkbox"> 每 2 秒自动刷新</label>
|
|
4331
|
-
<span id="count"></span>
|
|
4332
|
-
</header>
|
|
4333
|
-
<main>
|
|
4334
|
-
<table>
|
|
4335
|
-
<thead><tr><th>时间</th><th>分类</th><th>详情</th></tr></thead>
|
|
4336
|
-
<tbody id="rows"></tbody>
|
|
4337
|
-
</table>
|
|
4338
|
-
</main>
|
|
4339
|
-
<script>
|
|
4340
|
-
(function () {
|
|
4341
|
-
var timer = null;
|
|
4342
|
-
function fmtExtra(entry) {
|
|
4343
|
-
var e = entry.extra || {};
|
|
4344
|
-
var parts = [];
|
|
4345
|
-
if (e.method) parts.push(e.method + ' ' + (e.path || ''));
|
|
4346
|
-
if (typeof e.status === 'number') {
|
|
4347
|
-
parts.push('<span class="' + (e.status < 400 ? 'status-ok' : 'status-err') + '">status=' + e.status + '</span>');
|
|
4348
|
-
}
|
|
4349
|
-
if (typeof e.durationMs === 'number') {
|
|
4350
|
-
parts.push('<span class="' + (e.durationMs > 1000 ? 'dur-slow' : '') + '">' + e.durationMs + 'ms</span>');
|
|
4351
|
-
}
|
|
4352
|
-
if (e.session) parts.push('session=' + e.session);
|
|
4353
|
-
if (e.args) parts.push('args=' + JSON.stringify(e.args));
|
|
4354
|
-
if (e.type) parts.push('type=' + e.type);
|
|
4355
|
-
if (typeof e.seq === 'number') parts.push('seq=' + e.seq);
|
|
4356
|
-
if (typeof e.sinceStartMs === 'number') parts.push('sinceStart=' + e.sinceStartMs + 'ms');
|
|
4357
|
-
if (typeof e.totalDurationMs === 'number') parts.push('total=' + e.totalDurationMs + 'ms');
|
|
4358
|
-
if (typeof e.eventCount === 'number') parts.push('events=' + e.eventCount);
|
|
4359
|
-
if (e.error) parts.push('<span class="status-err">error=' + e.error + '</span>');
|
|
4360
|
-
var rest = {};
|
|
4361
|
-
Object.keys(e).forEach(function (k) {
|
|
4362
|
-
if (['method','path','status','durationMs','session','args','type','seq','sinceStartMs','totalDurationMs','eventCount','error'].indexOf(k) === -1) {
|
|
4363
|
-
rest[k] = e[k];
|
|
4364
|
-
}
|
|
4365
|
-
});
|
|
4366
|
-
if (Object.keys(rest).length) parts.push(JSON.stringify(rest));
|
|
4367
|
-
return parts.join(' ');
|
|
4368
|
-
}
|
|
4369
|
-
function escapeHtml(s) {
|
|
4370
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
4371
|
-
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
4372
|
-
});
|
|
4373
|
-
}
|
|
4374
|
-
function load() {
|
|
4375
|
-
var params = new URLSearchParams();
|
|
4376
|
-
var date = document.getElementById('f-date').value.trim();
|
|
4377
|
-
var category = document.getElementById('f-category').value.trim();
|
|
4378
|
-
var session = document.getElementById('f-session').value.trim();
|
|
4379
|
-
var keyword = document.getElementById('f-keyword').value.trim();
|
|
4380
|
-
var limit = document.getElementById('f-limit').value.trim();
|
|
4381
|
-
if (date) params.set('date', date);
|
|
4382
|
-
if (category) params.set('category', category);
|
|
4383
|
-
if (session) params.set('session', session);
|
|
4384
|
-
if (keyword) params.set('keyword', keyword);
|
|
4385
|
-
if (limit) params.set('limit', limit);
|
|
4386
|
-
fetch('/api/diag/logs?' + params.toString(), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
|
4387
|
-
.then(function (r) { return r.json(); })
|
|
4388
|
-
.then(function (data) {
|
|
4389
|
-
var entries = data.entries || [];
|
|
4390
|
-
document.getElementById('count').textContent = entries.length + ' 条(' + data.date + ')';
|
|
4391
|
-
var rows = entries.slice().reverse().map(function (entry) {
|
|
4392
|
-
return '<tr><td>' + escapeHtml(entry.ts) + '</td><td><span class="cat">' + escapeHtml(entry.category) + '</span></td><td>' + fmtExtra(entry) + '</td></tr>';
|
|
4393
|
-
});
|
|
4394
|
-
document.getElementById('rows').innerHTML = rows.join('');
|
|
4395
|
-
});
|
|
4396
|
-
}
|
|
4397
|
-
document.getElementById('btn-refresh').addEventListener('click', load);
|
|
4398
|
-
document.getElementById('f-auto').addEventListener('change', function (e) {
|
|
4399
|
-
if (timer) { clearInterval(timer); timer = null; }
|
|
4400
|
-
if (e.target.checked) { timer = setInterval(load, 2000); }
|
|
4401
|
-
});
|
|
4402
|
-
load();
|
|
4403
|
-
})();
|
|
4404
|
-
</script>
|
|
4405
|
-
</body>
|
|
4406
|
-
</html>
|
|
4407
|
-
`;
|
|
4408
|
-
}
|
|
4409
|
-
|
|
4410
4292
|
function toPositiveInt(value, fallback) {
|
|
4411
4293
|
const parsed = Number.parseInt(value, 10);
|
|
4412
4294
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
@@ -4700,23 +4582,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4700
4582
|
}
|
|
4701
4583
|
}
|
|
4702
4584
|
const routes = [
|
|
4703
|
-
// DIAG_LOG:临时诊断日志查询接口,配合 /logs 页面使用,排查完成后与
|
|
4704
|
-
// diagLog/readDiagLogEntries 一起整体删除即可(全局搜索 "DIAG_LOG")。
|
|
4705
|
-
{
|
|
4706
|
-
method: 'GET',
|
|
4707
|
-
match: currentPath => currentPath === '/api/diag/logs' ? [] : null,
|
|
4708
|
-
handler: async () => {
|
|
4709
|
-
const requestUrl = new URL(req.url || '/api/diag/logs', 'http://localhost');
|
|
4710
|
-
const entries = readDiagLogEntries(ctx, {
|
|
4711
|
-
date: requestUrl.searchParams.get('date') || undefined,
|
|
4712
|
-
category: requestUrl.searchParams.get('category') || undefined,
|
|
4713
|
-
session: requestUrl.searchParams.get('session') || undefined,
|
|
4714
|
-
keyword: requestUrl.searchParams.get('keyword') || undefined,
|
|
4715
|
-
limit: Number(requestUrl.searchParams.get('limit')) || undefined
|
|
4716
|
-
});
|
|
4717
|
-
sendJson(res, 200, { date: requestUrl.searchParams.get('date') || getLocalDateTag(), entries });
|
|
4718
|
-
}
|
|
4719
|
-
},
|
|
4720
4585
|
{
|
|
4721
4586
|
method: 'GET',
|
|
4722
4587
|
match: currentPath => currentPath === '/api/fs/directories' ? [] : null,
|
|
@@ -4827,7 +4692,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4827
4692
|
method: 'GET',
|
|
4828
4693
|
match: currentPath => currentPath === '/api/system/capacity' ? [] : null,
|
|
4829
4694
|
handler: async () => {
|
|
4830
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
4695
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
4831
4696
|
const diskPath = path.dirname(path.resolve(state.webConfigPath));
|
|
4832
4697
|
const report = estimateContainerCapacity({
|
|
4833
4698
|
runtimeCommand: ctx.dockerCmd || 'docker',
|
|
@@ -4904,7 +4769,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4904
4769
|
method: 'GET',
|
|
4905
4770
|
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
4906
4771
|
handler: async () => {
|
|
4907
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
4772
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
4908
4773
|
const names = new Set([
|
|
4909
4774
|
...Object.keys(containerMap),
|
|
4910
4775
|
...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
|
|
@@ -4917,7 +4782,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4917
4782
|
.map(agentSession => buildSessionSummary(ctx, state, containerMap, {
|
|
4918
4783
|
containerName: name,
|
|
4919
4784
|
agentId: agentSession.agentId
|
|
4920
|
-
}))
|
|
4785
|
+
}, history))
|
|
4921
4786
|
.filter(Boolean);
|
|
4922
4787
|
})
|
|
4923
4788
|
.sort(compareWebSessionCreatedDesc);
|
|
@@ -5302,7 +5167,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5302
5167
|
return;
|
|
5303
5168
|
}
|
|
5304
5169
|
|
|
5305
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
5170
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
5306
5171
|
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
5307
5172
|
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
|
|
5308
5173
|
}
|
|
@@ -5315,7 +5180,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5315
5180
|
if (!sessionRef) {
|
|
5316
5181
|
return;
|
|
5317
5182
|
}
|
|
5318
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
5183
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
5319
5184
|
const containerInfo = containerMap[sessionRef.containerName] || {};
|
|
5320
5185
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5321
5186
|
const effectiveTemplate = resolveEffectiveAgentPromptCommandForSession(
|
|
@@ -5413,7 +5278,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5413
5278
|
return;
|
|
5414
5279
|
}
|
|
5415
5280
|
|
|
5416
|
-
const containerMap = listWebManyoyoContainers(ctx
|
|
5281
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
5417
5282
|
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
5418
5283
|
sendJson(res, 200, {
|
|
5419
5284
|
saved: true,
|
|
@@ -5523,6 +5388,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5523
5388
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
5524
5389
|
return;
|
|
5525
5390
|
}
|
|
5391
|
+
// 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
|
|
5392
|
+
const runState = reserveWebAgentRun(state, sessionRef);
|
|
5526
5393
|
|
|
5527
5394
|
const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
5528
5395
|
mode: 'agent',
|
|
@@ -5542,6 +5409,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5542
5409
|
try {
|
|
5543
5410
|
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
5544
5411
|
} catch (e) {
|
|
5412
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
5545
5413
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
5546
5414
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
|
|
5547
5415
|
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
@@ -5562,7 +5430,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5562
5430
|
'Cache-Control': 'no-store',
|
|
5563
5431
|
'X-Accel-Buffering': 'no'
|
|
5564
5432
|
});
|
|
5565
|
-
|
|
5433
|
+
// agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
|
|
5434
|
+
// 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
|
|
5435
|
+
// 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
|
|
5436
|
+
// 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
|
|
5437
|
+
const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
|
|
5438
|
+
let lastStreamWriteAt = Date.now();
|
|
5439
|
+
const heartbeatTimer = setInterval(() => {
|
|
5440
|
+
if (res.writableEnded || res.destroyed) {
|
|
5441
|
+
return;
|
|
5442
|
+
}
|
|
5443
|
+
if (Date.now() - lastStreamWriteAt < heartbeatMs) {
|
|
5444
|
+
return;
|
|
5445
|
+
}
|
|
5446
|
+
sendNdjson(res, { type: 'ping' });
|
|
5447
|
+
lastStreamWriteAt = Date.now();
|
|
5448
|
+
}, Math.max(50, Math.floor(heartbeatMs / 2)));
|
|
5449
|
+
if (typeof heartbeatTimer.unref === 'function') {
|
|
5450
|
+
heartbeatTimer.unref();
|
|
5451
|
+
}
|
|
5452
|
+
const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
|
|
5453
|
+
const emitStreamEvent = (...args) => {
|
|
5454
|
+
lastStreamWriteAt = Date.now();
|
|
5455
|
+
return rawEmitStreamEvent(...args);
|
|
5456
|
+
};
|
|
5566
5457
|
emitStreamEvent({
|
|
5567
5458
|
type: 'meta',
|
|
5568
5459
|
containerName: sessionRef.containerName,
|
|
@@ -5591,40 +5482,31 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5591
5482
|
pending: true
|
|
5592
5483
|
});
|
|
5593
5484
|
|
|
5594
|
-
|
|
5595
|
-
let diagEventSeq = 0; // DIAG_LOG
|
|
5596
|
-
diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
|
|
5597
|
-
session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5598
|
-
agentProgram: agentMeta.agentProgram,
|
|
5599
|
-
command
|
|
5600
|
-
});
|
|
5485
|
+
let lastPartialPersistAt = 0;
|
|
5601
5486
|
try {
|
|
5602
5487
|
const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
5603
5488
|
agentProgram: agentMeta.agentProgram,
|
|
5489
|
+
// 复用路由入口占好的那把锁,而不是另起一份运行登记
|
|
5490
|
+
runState,
|
|
5604
5491
|
onEvent: event => {
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
seq: diagEventSeq,
|
|
5609
|
-
type: event && event.type,
|
|
5610
|
-
sinceStartMs: Date.now() - diagStreamStartedAt,
|
|
5611
|
-
textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
|
|
5612
|
-
contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
|
|
5613
|
-
});
|
|
5492
|
+
// token 级增量:只走网线,不进事件日志,历史落盘按
|
|
5493
|
+
// AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
|
|
5494
|
+
const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
|
|
5614
5495
|
if (event && event.type === 'trace' && event.text) {
|
|
5615
5496
|
traceLines.push(String(event.text));
|
|
5616
5497
|
if (event.traceEvent && typeof event.traceEvent === 'object') {
|
|
5617
5498
|
traceEvents.push(event.traceEvent);
|
|
5618
5499
|
}
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
});
|
|
5625
|
-
}
|
|
5500
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5501
|
+
content: traceLines.join('\n'),
|
|
5502
|
+
traceEvents: traceEvents.slice(),
|
|
5503
|
+
pending: true
|
|
5504
|
+
});
|
|
5626
5505
|
}
|
|
5627
5506
|
if (event && event.type === 'content_delta' && typeof event.content === 'string') {
|
|
5507
|
+
const now = Date.now();
|
|
5508
|
+
const shouldPersist = !isPartialDelta
|
|
5509
|
+
|| now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
|
|
5628
5510
|
if (!streamingReplyMessageId) {
|
|
5629
5511
|
const streamingReplyMessage = appendWebSessionMessage(
|
|
5630
5512
|
state.webHistoryDir,
|
|
@@ -5640,26 +5522,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5640
5522
|
streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
|
|
5641
5523
|
? streamingReplyMessage.id
|
|
5642
5524
|
: '';
|
|
5643
|
-
|
|
5525
|
+
lastPartialPersistAt = now;
|
|
5526
|
+
} else if (shouldPersist) {
|
|
5644
5527
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
5645
5528
|
content: event.content,
|
|
5646
5529
|
pending: true
|
|
5647
5530
|
});
|
|
5531
|
+
lastPartialPersistAt = now;
|
|
5648
5532
|
}
|
|
5649
5533
|
}
|
|
5534
|
+
if (isPartialDelta) {
|
|
5535
|
+
lastStreamWriteAt = Date.now();
|
|
5536
|
+
// 只发增量片段:前端在 content_chunk 上做追加,
|
|
5537
|
+
// reset 表示换了一条 assistant 消息、从空白重新开始
|
|
5538
|
+
sendNdjson(res, event.chunkReset === true
|
|
5539
|
+
? { type: 'content_chunk', text: '', reset: true }
|
|
5540
|
+
: { type: 'content_chunk', text: event.chunk });
|
|
5541
|
+
return;
|
|
5542
|
+
}
|
|
5650
5543
|
emitStreamEvent(event, resolveWebStreamControlEventType(event), {
|
|
5651
5544
|
transportType: event && event.type ? event.type : '',
|
|
5652
5545
|
text: event && (event.text || event.content) ? String(event.text || event.content) : ''
|
|
5653
5546
|
});
|
|
5654
5547
|
}
|
|
5655
5548
|
});
|
|
5656
|
-
diagLog(ctx, 'stream_exec_end', { // DIAG_LOG
|
|
5657
|
-
session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5658
|
-
totalDurationMs: Date.now() - diagStreamStartedAt,
|
|
5659
|
-
eventCount: diagEventSeq,
|
|
5660
|
-
interrupted: result.interrupted === true,
|
|
5661
|
-
exitCode: result.exitCode
|
|
5662
|
-
});
|
|
5663
5549
|
traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
|
|
5664
5550
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5665
5551
|
pending: false,
|
|
@@ -5723,6 +5609,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5723
5609
|
error: e && e.message ? e.message : 'Agent 执行失败'
|
|
5724
5610
|
}, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
|
|
5725
5611
|
} finally {
|
|
5612
|
+
clearInterval(heartbeatTimer);
|
|
5726
5613
|
res.end();
|
|
5727
5614
|
}
|
|
5728
5615
|
}
|
|
@@ -5743,7 +5630,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5743
5630
|
sendJson(res, 404, { error: '当前会话没有运行中的 agent 任务' });
|
|
5744
5631
|
return;
|
|
5745
5632
|
}
|
|
5746
|
-
appendWebSessionControlEvent(state, sessionRef, 'session.stopping');
|
|
5633
|
+
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopping');
|
|
5747
5634
|
sendJson(res, 200, { ok: true, stopping: true });
|
|
5748
5635
|
}
|
|
5749
5636
|
},
|
|
@@ -5762,7 +5649,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5762
5649
|
|
|
5763
5650
|
if (removeHistory) {
|
|
5764
5651
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5765
|
-
removeAllAgentHistoryArtifacts(state, sessionRef.containerName, Object.keys(history.agents || {}));
|
|
5652
|
+
removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, Object.keys(history.agents || {}));
|
|
5766
5653
|
removeWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
5767
5654
|
} else if (removedContainer) {
|
|
5768
5655
|
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'system', `容器 ${sessionRef.containerName} 已删除。`);
|
|
@@ -5791,7 +5678,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5791
5678
|
if (history.agents && typeof history.agents === 'object') {
|
|
5792
5679
|
if (removeHistory) {
|
|
5793
5680
|
delete history.agents[sessionRef.agentId];
|
|
5794
|
-
removeAllAgentHistoryArtifacts(state, sessionRef.containerName, [sessionRef.agentId]);
|
|
5681
|
+
removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, [sessionRef.agentId]);
|
|
5795
5682
|
} else if (history.agents[sessionRef.agentId]) {
|
|
5796
5683
|
history.agents[sessionRef.agentId].archived = true;
|
|
5797
5684
|
history.agents[sessionRef.agentId].updatedAt = new Date().toISOString();
|
|
@@ -5861,6 +5748,10 @@ async function startWebServer(options) {
|
|
|
5861
5748
|
showImagePullHint: options.showImagePullHint,
|
|
5862
5749
|
removeContainer: options.removeContainer,
|
|
5863
5750
|
logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
|
|
5751
|
+
// agent/stream 的保活心跳间隔,需小于反向代理的空闲读超时(nginx 默认 60s)
|
|
5752
|
+
agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
|
|
5753
|
+
? options.agentStreamHeartbeatMs
|
|
5754
|
+
: DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
|
|
5864
5755
|
colors: options.colors || {
|
|
5865
5756
|
GREEN: '',
|
|
5866
5757
|
CYAN: '',
|
|
@@ -5873,29 +5764,6 @@ async function startWebServer(options) {
|
|
|
5873
5764
|
throw new Error('Web 认证配置缺失,请设置 serve -U / serve -P');
|
|
5874
5765
|
}
|
|
5875
5766
|
|
|
5876
|
-
// DIAG_LOG:包一层记录每次 docker/podman 子进程调用的参数与耗时,覆盖
|
|
5877
|
-
// listWebManyoyoContainers、ensureWebContainer 等所有经由 ctx.dockerExecArgs
|
|
5878
|
-
// 发起的调用,不需要逐个调用点单独打点。
|
|
5879
|
-
if (typeof ctx.dockerExecArgs === 'function') {
|
|
5880
|
-
const rawDockerExecArgs = ctx.dockerExecArgs;
|
|
5881
|
-
ctx.dockerExecArgs = (args, execOptions) => {
|
|
5882
|
-
const startedAt = Date.now();
|
|
5883
|
-
let errorMessage = '';
|
|
5884
|
-
try {
|
|
5885
|
-
return rawDockerExecArgs(args, execOptions);
|
|
5886
|
-
} catch (e) {
|
|
5887
|
-
errorMessage = e && e.message ? e.message : String(e);
|
|
5888
|
-
throw e;
|
|
5889
|
-
} finally {
|
|
5890
|
-
diagLog(ctx, 'docker_exec', {
|
|
5891
|
-
args: Array.isArray(args) ? args : [],
|
|
5892
|
-
durationMs: Date.now() - startedAt,
|
|
5893
|
-
error: errorMessage || undefined
|
|
5894
|
-
});
|
|
5895
|
-
}
|
|
5896
|
-
};
|
|
5897
|
-
}
|
|
5898
|
-
|
|
5899
5767
|
const state = {
|
|
5900
5768
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
5901
5769
|
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
@@ -5903,11 +5771,6 @@ async function startWebServer(options) {
|
|
|
5903
5771
|
terminalSessions: new Map(),
|
|
5904
5772
|
agentRuns: new Map()
|
|
5905
5773
|
};
|
|
5906
|
-
// 跨请求复用同一个 FileEventStore 实例,append() 的增量缓存才能生效
|
|
5907
|
-
// (否则每次都 new 一个新实例,缓存永远命中不到)
|
|
5908
|
-
state.eventStore = new FileEventStore(state.webHistoryDir);
|
|
5909
|
-
// shouldFlushLiveHistory() 的节流状态:aggregateId -> 上次成功落盘的时间戳
|
|
5910
|
-
state._liveHistoryFlushThrottle = new Map();
|
|
5911
5774
|
|
|
5912
5775
|
ensureWebHistoryDir(state.webHistoryDir);
|
|
5913
5776
|
|
|
@@ -5930,20 +5793,6 @@ async function startWebServer(options) {
|
|
|
5930
5793
|
});
|
|
5931
5794
|
|
|
5932
5795
|
const server = http.createServer(async (req, res) => {
|
|
5933
|
-
// DIAG_LOG:记录每个 HTTP 请求从进入到响应完成(含长连接的 agent/stream,
|
|
5934
|
-
// 'finish' 在 chunked 响应整体结束时才触发)的方法/路径/状态码/耗时。
|
|
5935
|
-
const diagRequestStartedAt = Date.now();
|
|
5936
|
-
res.on('finish', () => {
|
|
5937
|
-
const rawPath = String(req.url || '').split('?')[0];
|
|
5938
|
-
const sessionMatch = rawPath.match(/^\/api\/sessions\/([^/]+)/);
|
|
5939
|
-
diagLog(ctx, 'http_request', {
|
|
5940
|
-
method: req.method,
|
|
5941
|
-
path: rawPath,
|
|
5942
|
-
status: res.statusCode,
|
|
5943
|
-
durationMs: Date.now() - diagRequestStartedAt,
|
|
5944
|
-
session: sessionMatch ? decodeURIComponent(sessionMatch[1]) : undefined
|
|
5945
|
-
});
|
|
5946
|
-
});
|
|
5947
5796
|
try {
|
|
5948
5797
|
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
5949
5798
|
const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
|
|
@@ -5978,13 +5827,6 @@ async function startWebServer(options) {
|
|
|
5978
5827
|
return;
|
|
5979
5828
|
}
|
|
5980
5829
|
|
|
5981
|
-
// DIAG_LOG:临时诊断日志查看页,排查完成后与 renderDiagLogsHtml、
|
|
5982
|
-
// /api/diag/logs 一起整体删除即可(全局搜索 "DIAG_LOG")。
|
|
5983
|
-
if (req.method === 'GET' && pathname === '/logs') {
|
|
5984
|
-
sendHtml(res, 200, renderDiagLogsHtml());
|
|
5985
|
-
return;
|
|
5986
|
-
}
|
|
5987
|
-
|
|
5988
5830
|
const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
|
|
5989
5831
|
if (req.method === 'GET' && appFrontendMatch) {
|
|
5990
5832
|
const assetName = appFrontendMatch[1];
|