@xcanwin/manyoyo 8.6.10 → 8.6.12
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/README.md +2 -2
- package/bin/manyoyo.js +12 -4
- package/docker/manyoyo.Dockerfile +1 -1
- package/docker/res/claude/settings.json +20 -0
- package/lib/agent-adapters/index.js +18 -1
- package/lib/app-update.js +15 -5
- package/lib/code-update.js +54 -7
- package/lib/container-network.js +48 -10
- package/lib/egress-sidecar.js +19 -5
- package/lib/image-agents.js +45 -0
- package/lib/init-config.js +2 -1
- package/lib/network-endpoints.js +56 -1
- package/lib/plugin/playwright.js +12 -5
- package/lib/provider-presets.js +86 -0
- package/lib/setup-cli.js +18 -4
- package/lib/setup.js +7 -1
- package/lib/web/AGENTS.md +3 -0
- package/lib/web/agent-queue.js +5 -1
- package/lib/web/auth-store.js +167 -0
- package/lib/web/container-manage.js +2 -2
- package/lib/web/denied-notify.js +5 -0
- package/lib/web/index.html +86 -86
- package/lib/web/rewind.js +75 -0
- package/lib/web/server.js +794 -68
- package/lib/web/snapshots.js +245 -0
- package/lib/web/uploads.js +114 -0
- package/package.json +6 -6
package/lib/web/server.js
CHANGED
|
@@ -19,7 +19,7 @@ const gitChanges = require('./git-changes');
|
|
|
19
19
|
const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
|
|
20
20
|
const { createNetworkManager, NETWORK_NAME } = require('../container-network');
|
|
21
21
|
const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../network-policy');
|
|
22
|
-
const { withEnvEndpoints } = require('../network-endpoints');
|
|
22
|
+
const { withEnvEndpoints, modelDomains, agentModelDomains } = require('../network-endpoints');
|
|
23
23
|
const { egressPaths, isSidecarName } = require('../egress-sidecar');
|
|
24
24
|
const { read: readDenied } = require('../egress-denied');
|
|
25
25
|
const { isBrowserBackground } = require('../browser-background');
|
|
@@ -38,6 +38,11 @@ const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
|
38
38
|
const { ensureImagePresent, pullImageProcess } = require('../image-pull');
|
|
39
39
|
const { waitForImport } = require('../offline-import');
|
|
40
40
|
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
|
|
41
|
+
const { createImageAgentProbe } = require('../image-agents');
|
|
42
|
+
const { createAuthStore } = require('./auth-store');
|
|
43
|
+
const webUploads = require('./uploads');
|
|
44
|
+
const webSnapshots = require('./snapshots');
|
|
45
|
+
const webRewind = require('./rewind');
|
|
41
46
|
const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
|
|
42
47
|
const { applyDoctorFixes } = require('../doctor');
|
|
43
48
|
const { writeConfigFileSecure } = require('../secure-file');
|
|
@@ -52,7 +57,7 @@ const {
|
|
|
52
57
|
resolveAgentPromptCommandTemplate,
|
|
53
58
|
buildAgentResumeCommand
|
|
54
59
|
} = require('../agent-resume');
|
|
55
|
-
const { resolveYoloCommand: resolveYoloCommandFromAdapters } = require('../agent-adapters');
|
|
60
|
+
const { resolveYoloCommand: resolveYoloCommandFromAdapters, withCodexBaseUrl } = require('../agent-adapters');
|
|
56
61
|
const {
|
|
57
62
|
createControlEvent,
|
|
58
63
|
validateControlEvent,
|
|
@@ -229,7 +234,9 @@ function createEmptyWebAgentSession(agentId, agentName) {
|
|
|
229
234
|
title: '',
|
|
230
235
|
firstPrompt: '',
|
|
231
236
|
pinned: false,
|
|
232
|
-
slashCommands: null
|
|
237
|
+
slashCommands: null,
|
|
238
|
+
// 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
|
|
239
|
+
rewindToUuid: ''
|
|
233
240
|
};
|
|
234
241
|
}
|
|
235
242
|
|
|
@@ -287,7 +294,8 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
287
294
|
title: sessionMeta.normalizeTitle(source.title),
|
|
288
295
|
firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
|
|
289
296
|
pinned: source.pinned === true,
|
|
290
|
-
slashCommands: normalizeStoredSlashCommands(source.slashCommands)
|
|
297
|
+
slashCommands: normalizeStoredSlashCommands(source.slashCommands),
|
|
298
|
+
rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
|
|
291
299
|
};
|
|
292
300
|
}
|
|
293
301
|
|
|
@@ -730,6 +738,53 @@ function removeWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messa
|
|
|
730
738
|
return removed;
|
|
731
739
|
}
|
|
732
740
|
|
|
741
|
+
// 删掉某条消息及之后的所有消息(回退对话)。改动的是稳定前缀,必须换新 epoch,增量同步据此让前端整页重置
|
|
742
|
+
function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
|
|
743
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
744
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
745
|
+
if (!agentSession) {
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
const index = agentSession.messages.findIndex(item => item && String(item.id || '') === String(messageId));
|
|
749
|
+
if (index === -1) {
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
const removed = agentSession.messages.splice(index);
|
|
753
|
+
agentSession.epoch = messagesView.createEpoch();
|
|
754
|
+
const timestamp = new Date().toISOString();
|
|
755
|
+
agentSession.updatedAt = timestamp;
|
|
756
|
+
history.updatedAt = timestamp;
|
|
757
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
758
|
+
return removed;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 从页面隐藏 / 取消隐藏某一组(用户消息 + 它的执行过程 + 回复):只改展示,agent 仍记得
|
|
762
|
+
function setWebSessionTurnHidden(webHistoryDir, sessionRef, userMessageId, hidden) {
|
|
763
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
764
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
765
|
+
if (!agentSession) {
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
const start = agentSession.messages.findIndex(item => item && String(item.id || '') === String(userMessageId) && item.role === 'user');
|
|
769
|
+
if (start === -1) {
|
|
770
|
+
return 0;
|
|
771
|
+
}
|
|
772
|
+
let end = start + 1;
|
|
773
|
+
while (end < agentSession.messages.length && agentSession.messages[end].role !== 'user') {
|
|
774
|
+
end += 1;
|
|
775
|
+
}
|
|
776
|
+
for (let i = start; i < end; i += 1) {
|
|
777
|
+
if (hidden) agentSession.messages[i].hidden = true;
|
|
778
|
+
else delete agentSession.messages[i].hidden;
|
|
779
|
+
}
|
|
780
|
+
agentSession.epoch = messagesView.createEpoch();
|
|
781
|
+
const timestamp = new Date().toISOString();
|
|
782
|
+
agentSession.updatedAt = timestamp;
|
|
783
|
+
history.updatedAt = timestamp;
|
|
784
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
785
|
+
return end - start;
|
|
786
|
+
}
|
|
787
|
+
|
|
733
788
|
function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
|
|
734
789
|
const history = loadWebSessionHistory(webHistoryDir, containerName);
|
|
735
790
|
history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
|
|
@@ -960,20 +1015,24 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
960
1015
|
const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
|
|
961
1016
|
// 环境说明:模板里已经写了 developer_instructions 就以用户的为准
|
|
962
1017
|
const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
|
|
1018
|
+
// 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
|
|
1019
|
+
const images = options && Array.isArray(options.images) ? options.images : [];
|
|
1020
|
+
const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
|
|
963
1021
|
const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
|
|
964
1022
|
? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
|
|
965
1023
|
: '';
|
|
966
1024
|
if (sessionId) {
|
|
967
1025
|
const promptIndex = suffix.indexOf('{prompt}');
|
|
968
1026
|
const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
|
|
969
|
-
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${resumeSuffix}`;
|
|
1027
|
+
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
|
|
970
1028
|
} else {
|
|
971
|
-
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${suffix}`;
|
|
1029
|
+
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
|
|
972
1030
|
}
|
|
973
1031
|
}
|
|
974
|
-
|
|
1032
|
+
// 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
|
|
1033
|
+
return withCodexBaseUrl(codexTemplate === templateText
|
|
975
1034
|
? renderAgentPromptCommand(templateText, prompt)
|
|
976
|
-
: renderAgentPromptCommand(codexTemplate, prompt);
|
|
1035
|
+
: renderAgentPromptCommand(codexTemplate, prompt));
|
|
977
1036
|
}
|
|
978
1037
|
|
|
979
1038
|
function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
@@ -1010,6 +1069,10 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
1010
1069
|
if (sessionId) {
|
|
1011
1070
|
flagSpecs.push({ flag: `-r ${sessionId}`, pattern: /(?:^|\s)-r(?:\s|$)/ });
|
|
1012
1071
|
}
|
|
1072
|
+
const resumeAtUuid = options && typeof options.resumeAtUuid === 'string' && webRewind.UUID_PATTERN.test(options.resumeAtUuid) ? options.resumeAtUuid : '';
|
|
1073
|
+
if (sessionId && resumeAtUuid) {
|
|
1074
|
+
flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
|
|
1075
|
+
}
|
|
1013
1076
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
1014
1077
|
if (model) {
|
|
1015
1078
|
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
@@ -2323,7 +2386,7 @@ function prepareCodexTraceEvent(payload) {
|
|
|
2323
2386
|
});
|
|
2324
2387
|
}
|
|
2325
2388
|
|
|
2326
|
-
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
2389
|
+
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
|
|
2327
2390
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2328
2391
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
2329
2392
|
const containerMap = listWebManyoyoContainers(ctx);
|
|
@@ -2377,9 +2440,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
|
2377
2440
|
}
|
|
2378
2441
|
}
|
|
2379
2442
|
|
|
2443
|
+
// Codex 用 --image 传图;其余 Agent 在提示词末尾写路径,让它们用读文件的工具去看
|
|
2444
|
+
const imagesByFlag = agentMeta.agentProgram === 'codex';
|
|
2445
|
+
const promptWithImages = imagesByFlag ? prompt : webUploads.appendImageNote(prompt, images);
|
|
2380
2446
|
const effectivePrompt = resumeSucceeded
|
|
2381
|
-
?
|
|
2382
|
-
: buildAgentPromptWithHistory(agentSession,
|
|
2447
|
+
? promptWithImages
|
|
2448
|
+
: buildAgentPromptWithHistory(agentSession, promptWithImages);
|
|
2383
2449
|
// 环境说明每次执行时现读配置:改了设置不用重建容器;运行配置名记在创建时的快照里(旧会话没有,用全局配置)
|
|
2384
2450
|
const configSnapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
2385
2451
|
const runName = history.runtimeSnapshot && typeof history.runtimeSnapshot.run === 'string' ? history.runtimeSnapshot.run : '';
|
|
@@ -2387,6 +2453,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
|
2387
2453
|
sessionId: sessionIdForRun,
|
|
2388
2454
|
model: agentSession.model || '',
|
|
2389
2455
|
forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
|
|
2456
|
+
images: imagesByFlag ? images : [],
|
|
2457
|
+
resumeAtUuid: agentSession.rewindToUuid || '',
|
|
2390
2458
|
instructions: buildAgentInstructions(
|
|
2391
2459
|
configSnapshot.parseError ? {} : configSnapshot.parsed,
|
|
2392
2460
|
runName,
|
|
@@ -2418,9 +2486,11 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
|
|
|
2418
2486
|
resumeAttempted: meta.resumeAttempted,
|
|
2419
2487
|
resumeSucceeded: meta.resumeSucceeded,
|
|
2420
2488
|
interrupted: result.interrupted === true,
|
|
2489
|
+
...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
|
|
2421
2490
|
...(turnUsage ? { usage: turnUsage } : {})
|
|
2422
2491
|
});
|
|
2423
2492
|
const patch = {
|
|
2493
|
+
rewindToUuid: '',
|
|
2424
2494
|
lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
|
|
2425
2495
|
lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
|
|
2426
2496
|
lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
|
|
@@ -2442,6 +2512,129 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
|
|
|
2442
2512
|
return replyMessage;
|
|
2443
2513
|
}
|
|
2444
2514
|
|
|
2515
|
+
// 每轮前后各拍一次工作目录快照(影子 git,见 lib/web/snapshots.js);失败 / 太大不影响这一轮,只是这一轮不能回退文件
|
|
2516
|
+
// 在容器里跑一段 bash 脚本并取 stdout(脚本里的路径 / 提交号都已转义或校验)。
|
|
2517
|
+
// 用 `bash -c` 而不是 execCommandInWebContainer 的 `/bin/bash -lc`:不走登录 shell,更快;也让测试里按 /bin/bash 识别 Agent 运行的假 docker 不会把它误当成一轮对话
|
|
2518
|
+
function runWebContainerScript(ctx, containerName, script, timeoutMs = 60000) {
|
|
2519
|
+
return new Promise(resolve => {
|
|
2520
|
+
let child;
|
|
2521
|
+
try {
|
|
2522
|
+
child = spawnContainerExec(ctx, containerName, { withEnv: false, command: ['bash', '-c', script] }, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
2523
|
+
} catch (e) {
|
|
2524
|
+
resolve('');
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
let out = '';
|
|
2528
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
2529
|
+
child.stdout.setEncoding('utf8');
|
|
2530
|
+
child.stdout.on('data', chunk => { if (out.length < 4 * 1024 * 1024) out += chunk; });
|
|
2531
|
+
child.once('close', () => { clearTimeout(timer); resolve(out); });
|
|
2532
|
+
child.once('error', () => { clearTimeout(timer); resolve(out); });
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
async function takeWebSnapshot(ctx, state, sessionRef, label) {
|
|
2537
|
+
const workDir = resolveWebSessionWorkDir(state, sessionRef);
|
|
2538
|
+
if (!workDir || workDir === '/') {
|
|
2539
|
+
return { skipped: 'unavailable' };
|
|
2540
|
+
}
|
|
2541
|
+
try {
|
|
2542
|
+
return webSnapshots.parseSnapshotOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildSnapshotCommand(workDir, label), 15000));
|
|
2543
|
+
} catch (e) {
|
|
2544
|
+
return { skipped: 'unavailable' };
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// 回退的目标必须是一条已经结束的 Agent 用户消息,且这个容器里没有任务在跑
|
|
2549
|
+
function findWebRewindTarget(state, sessionRef, messageId) {
|
|
2550
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
2551
|
+
return { status: 409, error: '任务进行中,暂时不能回退' };
|
|
2552
|
+
}
|
|
2553
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2554
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
2555
|
+
const id = String(messageId || '');
|
|
2556
|
+
const message = agentSession && agentSession.messages.find(item => item && String(item.id || '') === id);
|
|
2557
|
+
if (!message || message.role !== 'user' || message.mode === 'command') {
|
|
2558
|
+
return { status: 404, error: '没有找到这条提示词' };
|
|
2559
|
+
}
|
|
2560
|
+
if (message.pending === true) {
|
|
2561
|
+
return { status: 409, error: '这一轮还在进行中' };
|
|
2562
|
+
}
|
|
2563
|
+
return { history, agentSession, message };
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
async function rewindWebSession(ctx, state, sessionRef, messageId, scope) {
|
|
2567
|
+
const target = findWebRewindTarget(state, sessionRef, messageId);
|
|
2568
|
+
if (target.error) {
|
|
2569
|
+
return target;
|
|
2570
|
+
}
|
|
2571
|
+
// 回退期间占住这个容器的运行锁:恢复脚本最长跑 2 分钟,这段时间里不能让新的一轮开跑(文件会被还原、新消息会被删)
|
|
2572
|
+
const runState = reserveWebAgentRun(state, sessionRef);
|
|
2573
|
+
try {
|
|
2574
|
+
return await rewindWebSessionLocked(ctx, state, sessionRef, scope, target);
|
|
2575
|
+
} finally {
|
|
2576
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
|
|
2581
|
+
const { history, agentSession, message } = target;
|
|
2582
|
+
let filesRestored = false;
|
|
2583
|
+
if (scope !== 'conversation') {
|
|
2584
|
+
if (!webSnapshots.isSnapId(message.snapBefore)) {
|
|
2585
|
+
return { status: 409, error: '这一轮没有文件快照,不能回退文件' };
|
|
2586
|
+
}
|
|
2587
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
2588
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestoreCommand(resolveWebSessionWorkDir(state, sessionRef), message.snapBefore), 120000);
|
|
2589
|
+
const restored = webSnapshots.parseRestoreOutput(out);
|
|
2590
|
+
if (!restored.ok) {
|
|
2591
|
+
// 先要给“现在”拍一张快照(让回退本身能撤销);有超过 20MB 的大文件或快照仓库超过 1GB 时拍不了,宁可不回退
|
|
2592
|
+
if (restored.reason === 'too-large') {
|
|
2593
|
+
return { status: 409, error: '工作目录里有超过 20MB 的大文件(或快照已超过 1GB),没法先给当前状态留快照,所以不能回退文件;只回退对话没有这个限制' };
|
|
2594
|
+
}
|
|
2595
|
+
return { status: 500, error: '恢复文件失败,工作目录没有改动' };
|
|
2596
|
+
}
|
|
2597
|
+
filesRestored = true;
|
|
2598
|
+
}
|
|
2599
|
+
let conversationMode = '';
|
|
2600
|
+
if (scope !== 'files') {
|
|
2601
|
+
const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
|
|
2602
|
+
history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
|
|
2603
|
+
)).agentProgram;
|
|
2604
|
+
let engineSessionId = agentSession.engineSessionId || '';
|
|
2605
|
+
let rewindToUuid = '';
|
|
2606
|
+
if (!engineSessionId) {
|
|
2607
|
+
conversationMode = 'precise';
|
|
2608
|
+
} else if (program === 'claude') {
|
|
2609
|
+
const command = webRewind.buildClaudeRecordsCommand(engineSessionId);
|
|
2610
|
+
const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
|
|
2611
|
+
const pick = webRewind.pickClaudeResumeAt(records, { text: message.content, timeMs: Date.parse(message.timestamp) });
|
|
2612
|
+
if (pick.found && pick.uuid) {
|
|
2613
|
+
rewindToUuid = pick.uuid;
|
|
2614
|
+
conversationMode = 'precise';
|
|
2615
|
+
} else if (pick.found) {
|
|
2616
|
+
// 回退到第一轮:Claude 那边什么都不需要记得,开新会话
|
|
2617
|
+
engineSessionId = '';
|
|
2618
|
+
conversationMode = 'precise';
|
|
2619
|
+
} else {
|
|
2620
|
+
engineSessionId = '';
|
|
2621
|
+
conversationMode = 'history';
|
|
2622
|
+
}
|
|
2623
|
+
} else {
|
|
2624
|
+
// 其它 Agent 没有精确回退:丢掉引擎会话,下一轮用剩下的聊天记录注入(不丢数据,只丢工具调用等细节)
|
|
2625
|
+
engineSessionId = '';
|
|
2626
|
+
conversationMode = 'history';
|
|
2627
|
+
}
|
|
2628
|
+
const removed = removeWebSessionMessagesFrom(state.webHistoryDir, sessionRef, message.id);
|
|
2629
|
+
if (!removed.length) {
|
|
2630
|
+
return { status: 409, error: '这条提示词已经不在了' };
|
|
2631
|
+
}
|
|
2632
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
|
|
2633
|
+
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
|
|
2634
|
+
}
|
|
2635
|
+
return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2445
2638
|
function resolveWebSessionWorkDir(state, sessionRef) {
|
|
2446
2639
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2447
2640
|
const applied = history.applied && typeof history.applied === 'object' ? history.applied : {};
|
|
@@ -2537,48 +2730,21 @@ function parseCookies(req) {
|
|
|
2537
2730
|
return cookies;
|
|
2538
2731
|
}
|
|
2539
2732
|
|
|
2540
|
-
function pruneExpiredWebAuthSessions(state) {
|
|
2541
|
-
const now = Date.now();
|
|
2542
|
-
for (const [sessionId, session] of state.authSessions.entries()) {
|
|
2543
|
-
if (!session || session.expiresAt <= now) {
|
|
2544
|
-
state.authSessions.delete(sessionId);
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
2733
|
function createWebAuthSession(state, username) {
|
|
2550
|
-
|
|
2551
|
-
const sessionId = crypto.randomBytes(24).toString('hex');
|
|
2552
|
-
state.authSessions.set(sessionId, {
|
|
2553
|
-
username,
|
|
2554
|
-
expiresAt: Date.now() + WEB_AUTH_TTL_SECONDS * 1000
|
|
2555
|
-
});
|
|
2556
|
-
return sessionId;
|
|
2734
|
+
return state.authStore.create(username);
|
|
2557
2735
|
}
|
|
2558
2736
|
|
|
2559
2737
|
function getWebAuthSession(state, req) {
|
|
2560
|
-
|
|
2561
|
-
const cookies = parseCookies(req);
|
|
2562
|
-
const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
|
|
2738
|
+
const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
|
|
2563
2739
|
if (!sessionId) return null;
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
if (!session) return null;
|
|
2567
|
-
if (session.expiresAt <= Date.now()) {
|
|
2568
|
-
state.authSessions.delete(sessionId);
|
|
2569
|
-
return null;
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
// Sliding session expiration
|
|
2573
|
-
session.expiresAt = Date.now() + WEB_AUTH_TTL_SECONDS * 1000;
|
|
2574
|
-
return { sessionId, username: session.username };
|
|
2740
|
+
const session = state.authStore.touch(sessionId);
|
|
2741
|
+
return session ? { sessionId, username: session.username } : null;
|
|
2575
2742
|
}
|
|
2576
2743
|
|
|
2577
2744
|
function clearWebAuthSession(state, req) {
|
|
2578
|
-
const
|
|
2579
|
-
const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
|
|
2745
|
+
const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
|
|
2580
2746
|
if (sessionId) {
|
|
2581
|
-
state.
|
|
2747
|
+
state.authStore.remove(sessionId);
|
|
2582
2748
|
}
|
|
2583
2749
|
}
|
|
2584
2750
|
|
|
@@ -3132,6 +3298,33 @@ function buildStaticContainerRuntime(ctx, containerName) {
|
|
|
3132
3298
|
};
|
|
3133
3299
|
}
|
|
3134
3300
|
|
|
3301
|
+
function normalizeFirstConfig(value, sourceLabel) {
|
|
3302
|
+
if (value === undefined || value === null) return {};
|
|
3303
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
3304
|
+
throw new Error(`${sourceLabel} 的 first 必须是对象(map),例如 {"shell":"init.sh"}`);
|
|
3305
|
+
}
|
|
3306
|
+
return value;
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// first.envFile 里已存在的文件内容 + first.env,后者优先(与 CLI 一致);返回 KEY=VALUE 行
|
|
3310
|
+
function buildFirstEnvLines(firstResolved) {
|
|
3311
|
+
const lines = [];
|
|
3312
|
+
(firstResolved.envFile || []).forEach(file => {
|
|
3313
|
+
const resolvedPath = expandHomeAliasPath(file);
|
|
3314
|
+
if (!path.isAbsolute(resolvedPath)) {
|
|
3315
|
+
throw new Error(`环境变量文件仅支持绝对路径: ${file}`);
|
|
3316
|
+
}
|
|
3317
|
+
if (!fs.existsSync(resolvedPath)) return;
|
|
3318
|
+
containerState.parseEnvFileContent(fs.readFileSync(resolvedPath, 'utf-8')).entries
|
|
3319
|
+
.forEach(entry => lines.push(`${entry.key}=${entry.value}`));
|
|
3320
|
+
});
|
|
3321
|
+
Object.entries(firstResolved.env || {}).forEach(([key, value]) => {
|
|
3322
|
+
const parsed = parseEnvEntry(`${key}=${value}`);
|
|
3323
|
+
lines.push(`${parsed.key}=${parsed.value}`);
|
|
3324
|
+
});
|
|
3325
|
+
return lines;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3135
3328
|
function buildCreateRuntime(ctx, state, payload) {
|
|
3136
3329
|
const body = toPlainObject(payload);
|
|
3137
3330
|
const requestOptions = toPlainObject(body.createOptions);
|
|
@@ -3157,6 +3350,13 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3157
3350
|
const requestVolumeList = hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : [];
|
|
3158
3351
|
const requestPortList = hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : [];
|
|
3159
3352
|
|
|
3353
|
+
// first(首次命令):请求里带了就只用它(复制容器时来自快照,已合并过),否则全局 + runs.<name>
|
|
3354
|
+
const hasRequestFirst = hasOwn(requestOptions, 'first');
|
|
3355
|
+
const globalFirstConfig = hasRequestFirst ? {} : normalizeFirstConfig(config.first, '全局配置');
|
|
3356
|
+
const runFirstConfig = hasRequestFirst
|
|
3357
|
+
? normalizeFirstConfig(requestOptions.first, 'createOptions')
|
|
3358
|
+
: normalizeFirstConfig(runConfig.first, '运行配置');
|
|
3359
|
+
|
|
3160
3360
|
const resolvedBase = resolveRuntimeConfig({
|
|
3161
3361
|
cliOptions: {
|
|
3162
3362
|
hostPath: requestOptions.hostPath,
|
|
@@ -3171,8 +3371,8 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3171
3371
|
},
|
|
3172
3372
|
globalConfig: config,
|
|
3173
3373
|
runConfig,
|
|
3174
|
-
globalFirstConfig
|
|
3175
|
-
runFirstConfig
|
|
3374
|
+
globalFirstConfig,
|
|
3375
|
+
runFirstConfig,
|
|
3176
3376
|
defaults: {
|
|
3177
3377
|
hostPath: ctx.hostPath,
|
|
3178
3378
|
containerName: `my-${ctx.formatDate()}`,
|
|
@@ -3312,6 +3512,19 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3312
3512
|
});
|
|
3313
3513
|
}
|
|
3314
3514
|
|
|
3515
|
+
const firstResolved = resolvedBase.first;
|
|
3516
|
+
const firstCommand = String(firstResolved.exec.shell || '').trim()
|
|
3517
|
+
? `${firstResolved.exec.prefix || ''}${firstResolved.exec.shell}${firstResolved.exec.suffix || ''}`
|
|
3518
|
+
: '';
|
|
3519
|
+
// 写进快照的 first 已经是合并后的结果,复制容器时当作请求里的 first 使用
|
|
3520
|
+
const firstSnapshot = {
|
|
3521
|
+
shellPrefix: pickFirstString(runFirstConfig.shellPrefix, globalFirstConfig.shellPrefix),
|
|
3522
|
+
shell: pickFirstString(runFirstConfig.shell, globalFirstConfig.shell),
|
|
3523
|
+
shellSuffix: pickFirstString(runFirstConfig.shellSuffix, globalFirstConfig.shellSuffix),
|
|
3524
|
+
env: firstResolved.env || {},
|
|
3525
|
+
envFile: firstResolved.envFile || []
|
|
3526
|
+
};
|
|
3527
|
+
|
|
3315
3528
|
return {
|
|
3316
3529
|
containerName,
|
|
3317
3530
|
hostPath,
|
|
@@ -3322,6 +3535,7 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3322
3535
|
containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
|
|
3323
3536
|
containerEnvs,
|
|
3324
3537
|
envFiles,
|
|
3538
|
+
first: firstCommand ? { command: firstCommand, envLines: buildFirstEnvLines(firstResolved) } : null,
|
|
3325
3539
|
containerVolumes,
|
|
3326
3540
|
containerPorts,
|
|
3327
3541
|
autostart: manage.autostart,
|
|
@@ -3349,6 +3563,7 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3349
3563
|
ports: resolvedBase.ports || [],
|
|
3350
3564
|
autostart: manage.autostart,
|
|
3351
3565
|
systemPrompt,
|
|
3566
|
+
first: firstSnapshot,
|
|
3352
3567
|
// 复制容器时端口暴露(宿主机端口会冲突)与 peers(按 id 引用别的容器)不带过去
|
|
3353
3568
|
network: { ...manage.network, expose: [], peers: { inbound: [] } }
|
|
3354
3569
|
},
|
|
@@ -3434,6 +3649,7 @@ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
|
|
|
3434
3649
|
ports: runtimeSnapshot.ports || [],
|
|
3435
3650
|
autostart: runtimeSnapshot.autostart || '',
|
|
3436
3651
|
systemPrompt: runtimeSnapshot.systemPrompt || undefined,
|
|
3652
|
+
first: runtimeSnapshot.first || undefined,
|
|
3437
3653
|
network: runtimeSnapshot.network || undefined,
|
|
3438
3654
|
// 快照来自服务端、原容器创建时已确认过,复制不必再确认
|
|
3439
3655
|
confirmRisk: runtimeSnapshot.network ? true : undefined
|
|
@@ -3636,6 +3852,22 @@ async function isWebImagePresent(ctx, imageRef) {
|
|
|
3636
3852
|
}
|
|
3637
3853
|
}
|
|
3638
3854
|
|
|
3855
|
+
// `images --format` 的输出 → [{ name, tag, id, created, size }]:去掉悬空镜像,只留版本号合规(x.y.z-后缀)的,同名同版本去重
|
|
3856
|
+
function parseWebImageList(output) {
|
|
3857
|
+
const seen = new Set();
|
|
3858
|
+
const images = [];
|
|
3859
|
+
for (const line of String(output || '').split('\n')) {
|
|
3860
|
+
const [name, tag, id, created, size] = line.split('\t').map(part => (part || '').trim());
|
|
3861
|
+
if (!name || !tag || name === '<none>' || tag === '<none>') continue;
|
|
3862
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/:-]*$/.test(name) || !/^\d+\.\d+\.\d+-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tag)) continue;
|
|
3863
|
+
const key = `${name}:${tag}`;
|
|
3864
|
+
if (seen.has(key)) continue;
|
|
3865
|
+
seen.add(key);
|
|
3866
|
+
images.push({ name, tag, id: id || '', created: created || '', size: size || '' });
|
|
3867
|
+
}
|
|
3868
|
+
return images;
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3639
3871
|
function createImagePreparingError(message) {
|
|
3640
3872
|
const error = new Error(message);
|
|
3641
3873
|
error.statusCode = 409;
|
|
@@ -3729,7 +3961,7 @@ async function cleanupStaleSetupTestContainers(ctx) {
|
|
|
3729
3961
|
}
|
|
3730
3962
|
|
|
3731
3963
|
// Agent 一轮对话里,容器被过滤代理拦下的新域名(浏览器后台请求不算):只在 allowlist 容器上轮询
|
|
3732
|
-
function createWebDeniedNotifier(ctx, sessionRef, emit) {
|
|
3964
|
+
function createWebDeniedNotifier(ctx, sessionRef, emit, modelOptions = {}) {
|
|
3733
3965
|
try {
|
|
3734
3966
|
const homeDir = ctx.homeDir || os.homedir();
|
|
3735
3967
|
const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
|
|
@@ -3737,10 +3969,20 @@ function createWebDeniedNotifier(ctx, sessionRef, emit) {
|
|
|
3737
3969
|
const deniedDir = egressPaths(homeDir).denied;
|
|
3738
3970
|
const policyOf = () => normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined);
|
|
3739
3971
|
if (!usesProxy(policyOf())) return null;
|
|
3972
|
+
// 模型域名:env 里的 URL 域名 + 该 Agent 没配 BASE_URL 时的官方域名;每次现读 env(用户可能刚改过)
|
|
3973
|
+
const isModelHost = host => {
|
|
3974
|
+
const envLines = [
|
|
3975
|
+
...containerState.readEnv(homeDir, id).entries.map(entry => `${entry.key}=${entry.value}`),
|
|
3976
|
+
...containerState.envFileEntries(homeDir, id).map(entry => `${entry.key}=${entry.value}`)
|
|
3977
|
+
];
|
|
3978
|
+
return agentModelDomains(envLines, modelOptions.agentProgram).includes(String(host).toLowerCase());
|
|
3979
|
+
};
|
|
3740
3980
|
return createDeniedNotifier({
|
|
3741
3981
|
read: () => readDenied(deniedDir, id),
|
|
3742
3982
|
isIgnored: host => isBrowserBackground(host) || domainRuleAllows(policyOf(), host),
|
|
3743
|
-
emit
|
|
3983
|
+
emit,
|
|
3984
|
+
isModelHost,
|
|
3985
|
+
onModelDenied: modelOptions.onModelDenied
|
|
3744
3986
|
});
|
|
3745
3987
|
} catch (e) {
|
|
3746
3988
|
return null;
|
|
@@ -3967,6 +4209,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3967
4209
|
'system',
|
|
3968
4210
|
`容器 ${runtime.containerName} 已创建并启动。`
|
|
3969
4211
|
);
|
|
4212
|
+
if (runtime.first) {
|
|
4213
|
+
const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
|
|
4214
|
+
appendWebSessionMessage(
|
|
4215
|
+
state.webHistoryDir,
|
|
4216
|
+
messageSessionRef || runtime.containerName,
|
|
4217
|
+
'system',
|
|
4218
|
+
firstResult.exitCode === 0 ? '首次命令已执行' : formatWebFirstFailure(firstResult, runtime.first)
|
|
4219
|
+
);
|
|
4220
|
+
}
|
|
3970
4221
|
return;
|
|
3971
4222
|
}
|
|
3972
4223
|
|
|
@@ -3983,6 +4234,58 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3983
4234
|
await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
|
|
3984
4235
|
}
|
|
3985
4236
|
|
|
4237
|
+
const WEB_FIRST_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
4238
|
+
const WEB_FIRST_COMMAND_OUTPUT_CHARS = 500;
|
|
4239
|
+
|
|
4240
|
+
// 新建容器后执行一次 first(与 CLI 的 executeFirstCommand 同语义,经 buildExecArgs 带上 first.env);
|
|
4241
|
+
// 失败只返回结果,不删容器、不抛错
|
|
4242
|
+
function runWebFirstCommand(ctx, containerName, first) {
|
|
4243
|
+
return new Promise(resolve => {
|
|
4244
|
+
const tail = createTailBuffer(WEB_FIRST_COMMAND_OUTPUT_CHARS * 4);
|
|
4245
|
+
let settled = false;
|
|
4246
|
+
let timer = null;
|
|
4247
|
+
const finish = result => {
|
|
4248
|
+
if (settled) return;
|
|
4249
|
+
settled = true;
|
|
4250
|
+
if (timer) clearTimeout(timer);
|
|
4251
|
+
resolve({ ...result, output: tail.text() });
|
|
4252
|
+
};
|
|
4253
|
+
let child;
|
|
4254
|
+
try {
|
|
4255
|
+
child = spawnContainerExec(
|
|
4256
|
+
ctx,
|
|
4257
|
+
containerName,
|
|
4258
|
+
{ extraEnv: first.envLines, command: ['/bin/bash', '-c', first.command] },
|
|
4259
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
4260
|
+
);
|
|
4261
|
+
} catch (e) {
|
|
4262
|
+
tail.push(e && e.message ? e.message : '');
|
|
4263
|
+
finish({ exitCode: 1 });
|
|
4264
|
+
return;
|
|
4265
|
+
}
|
|
4266
|
+
timer = setTimeout(() => {
|
|
4267
|
+
tail.push('\n[等了 10 分钟仍未结束,已停止等待(容器里的命令可能还在运行)]');
|
|
4268
|
+
child.kill('SIGKILL');
|
|
4269
|
+
finish({ exitCode: 124 });
|
|
4270
|
+
}, ctx.firstCommandTimeoutMs || WEB_FIRST_COMMAND_TIMEOUT_MS);
|
|
4271
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
4272
|
+
stream.setEncoding('utf8');
|
|
4273
|
+
stream.on('data', chunk => tail.push(chunk));
|
|
4274
|
+
}
|
|
4275
|
+
child.on('error', error => {
|
|
4276
|
+
tail.push(error && error.message ? error.message : '');
|
|
4277
|
+
finish({ exitCode: 1 });
|
|
4278
|
+
});
|
|
4279
|
+
child.on('close', (code, signal) => finish({ exitCode: typeof code === 'number' ? code : (signal ? 1 : 0) }));
|
|
4280
|
+
});
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
function formatWebFirstFailure(result, first) {
|
|
4284
|
+
const secrets = first.envLines.map(line => line.slice(line.indexOf('=') + 1));
|
|
4285
|
+
const text = sanitizeServeLogText(redactSecrets(result.output, secrets)).trim().slice(-WEB_FIRST_COMMAND_OUTPUT_CHARS);
|
|
4286
|
+
return `首次命令失败(退出码 ${result.exitCode}):${text}`;
|
|
4287
|
+
}
|
|
4288
|
+
|
|
3986
4289
|
async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
|
|
3987
4290
|
const opts = options && typeof options === 'object' ? options : {};
|
|
3988
4291
|
const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
|
|
@@ -4204,6 +4507,25 @@ try {
|
|
|
4204
4507
|
`);
|
|
4205
4508
|
}
|
|
4206
4509
|
|
|
4510
|
+
// 只判断路径是否存在(点击聊天里的路径时用);不存在不是错误,返回 exists:false
|
|
4511
|
+
function buildContainerPathExistsCommand(requestedPath) {
|
|
4512
|
+
return buildWebContainerNodeCommand(`
|
|
4513
|
+
// __MANYOYO_FS_EXISTS__
|
|
4514
|
+
const fs = require('fs');
|
|
4515
|
+
|
|
4516
|
+
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
4517
|
+
|
|
4518
|
+
let result = { exists: false };
|
|
4519
|
+
try {
|
|
4520
|
+
const stat = fs.statSync(requestedPath);
|
|
4521
|
+
result = { exists: true, type: stat.isDirectory() ? 'dir' : (stat.isFile() ? 'file' : 'other') };
|
|
4522
|
+
} catch (e) {
|
|
4523
|
+
result = { exists: false };
|
|
4524
|
+
}
|
|
4525
|
+
process.stdout.write(JSON.stringify(result));
|
|
4526
|
+
`);
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4207
4529
|
function buildContainerFileReadCommand(requestedPath, options = {}) {
|
|
4208
4530
|
const opts = options && typeof options === 'object' ? options : {};
|
|
4209
4531
|
const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes > 0
|
|
@@ -4930,7 +5252,7 @@ function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
|
|
|
4930
5252
|
}
|
|
4931
5253
|
ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
|
|
4932
5254
|
};
|
|
4933
|
-
runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
|
|
5255
|
+
runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse(), Array.isArray(item.images) ? item.images : [])
|
|
4934
5256
|
.then(turn => {
|
|
4935
5257
|
if (turn && turn.busy) putBack('busy');
|
|
4936
5258
|
else if (turn && turn.error) putBack(turn.error);
|
|
@@ -4960,6 +5282,8 @@ function listLiveRunsInContainer(ctx, containerName) {
|
|
|
4960
5282
|
// 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
|
|
4961
5283
|
// 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
|
|
4962
5284
|
async function resumeQueuedAgentTurns(ctx, state) {
|
|
5285
|
+
// 先等启动对账:否则会在还挂着旧 pending 的会话上再开新一轮
|
|
5286
|
+
try { await state.orphanReconcileReady; } catch (e) { /* 对账失败不影响排队恢复 */ }
|
|
4963
5287
|
let containers = {};
|
|
4964
5288
|
try {
|
|
4965
5289
|
containers = listWebManyoyoContainers(ctx);
|
|
@@ -5073,10 +5397,135 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
|
|
|
5073
5397
|
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
5074
5398
|
}
|
|
5075
5399
|
|
|
5400
|
+
// ---- serve 启动对账 ----
|
|
5401
|
+
// serve 重启会丢掉内存里的运行登记,历史里留着 pending=true 的消息,前端据此永远显示“进行中”。
|
|
5402
|
+
// 启动时记下当时就 pending 的那批消息 id(之后新发起的轮次不碰),再按容器里是否还有活着的运行分两种处理:
|
|
5403
|
+
// 没有 → 立即标成已中断;还有(docker 下容器里的 agent 会比 serve 活得久)→ 每 5 秒查一次,跑完再标。
|
|
5404
|
+
const WEB_ORPHAN_POLL_MS = 5000;
|
|
5405
|
+
const ORPHAN_NOTE_INTERRUPTED = {
|
|
5406
|
+
trace: '服务重启,本轮已中断',
|
|
5407
|
+
reply: '服务重启时这一轮被中断了。可以发送“继续”让它接着做。'
|
|
5408
|
+
};
|
|
5409
|
+
const ORPHAN_NOTE_FINISHED = {
|
|
5410
|
+
trace: '服务重启,本轮在后台跑完,结果未接收',
|
|
5411
|
+
reply: '服务重启后这一轮在后台跑完了,但结果没能接收;可以发送“继续”让它总结。'
|
|
5412
|
+
};
|
|
5413
|
+
|
|
5414
|
+
function collectStartupPending(ctx, state) {
|
|
5415
|
+
const entries = [];
|
|
5416
|
+
let names = [];
|
|
5417
|
+
try {
|
|
5418
|
+
names = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
5419
|
+
} catch (e) {
|
|
5420
|
+
ctx.logger.warn('orphan reconcile: list history failed', { message: e && e.message });
|
|
5421
|
+
return entries;
|
|
5422
|
+
}
|
|
5423
|
+
for (const containerName of names) {
|
|
5424
|
+
try {
|
|
5425
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
5426
|
+
for (const [agentId, agentSession] of Object.entries(history.agents || {})) {
|
|
5427
|
+
const ids = (agentSession.messages || []).filter(message => message && message.pending === true).map(message => message.id);
|
|
5428
|
+
if (ids.length) entries.push({ containerName, agentId, ids: new Set(ids) });
|
|
5429
|
+
}
|
|
5430
|
+
} catch (e) {
|
|
5431
|
+
ctx.logger.warn('orphan reconcile: skip unreadable history', { container: containerName, message: e && e.message });
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
5434
|
+
return entries;
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5437
|
+
// 只改启动时记下的那些消息;这一轮的执行过程末尾补一行,最终回复(没有流式半截回复就新建一条)写一句说明
|
|
5438
|
+
function finalizeOrphanEntry(ctx, state, entry, note) {
|
|
5439
|
+
const sessionRef = { containerName: entry.containerName, agentId: entry.agentId };
|
|
5440
|
+
const history = loadWebSessionHistory(state.webHistoryDir, entry.containerName);
|
|
5441
|
+
const agentSession = getWebAgentSession(history, entry.agentId, { includeArchived: true });
|
|
5442
|
+
if (!agentSession || !Array.isArray(agentSession.messages)) return;
|
|
5443
|
+
const running = state.agentRuns.get(entry.containerName);
|
|
5444
|
+
const newerRunInSession = Boolean(running && running.sessionRef && running.sessionRef.agentId === entry.agentId);
|
|
5445
|
+
let touched = false;
|
|
5446
|
+
let replied = false;
|
|
5447
|
+
for (const message of agentSession.messages) {
|
|
5448
|
+
if (!message || message.pending !== true || !entry.ids.has(message.id)) continue;
|
|
5449
|
+
message.pending = false;
|
|
5450
|
+
message.interrupted = true;
|
|
5451
|
+
touched = true;
|
|
5452
|
+
if (message.streamTrace) {
|
|
5453
|
+
message.content = `${message.content || ''}\n[任务] ${note.trace}`;
|
|
5454
|
+
} else if (message.streamingReply) {
|
|
5455
|
+
message.streamingReply = false;
|
|
5456
|
+
message.content = message.content ? `${message.content}\n\n${note.reply}` : note.reply;
|
|
5457
|
+
replied = true;
|
|
5458
|
+
}
|
|
5459
|
+
}
|
|
5460
|
+
if (!touched) return;
|
|
5461
|
+
const timestamp = new Date().toISOString();
|
|
5462
|
+
agentSession.updatedAt = timestamp;
|
|
5463
|
+
history.updatedAt = timestamp;
|
|
5464
|
+
saveWebSessionHistory(state.webHistoryDir, entry.containerName, history);
|
|
5465
|
+
// 这个会话已经有新的一轮在跑:不在末尾插入说明,免得夹进新一轮的消息中间
|
|
5466
|
+
if (!replied && !newerRunInSession) {
|
|
5467
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', note.reply, { mode: 'agent', interrupted: true });
|
|
5468
|
+
}
|
|
5469
|
+
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'process.interrupted', { reason: 'serve-restart' });
|
|
5470
|
+
}
|
|
5471
|
+
|
|
5472
|
+
function isWebContainerRunning(ctx, containerName) {
|
|
5473
|
+
try {
|
|
5474
|
+
return Boolean(ctx.containerExists(containerName)) && ctx.getContainerStatus(containerName) === 'running';
|
|
5475
|
+
} catch (e) {
|
|
5476
|
+
return false;
|
|
5477
|
+
}
|
|
5478
|
+
}
|
|
5479
|
+
|
|
5480
|
+
async function waitForRuntimeReady(ctx) {
|
|
5481
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
5482
|
+
while (ctx.runtimeState && ctx.runtimeState.status === 'starting' && Date.now() < deadline) {
|
|
5483
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
|
|
5487
|
+
async function reconcileOrphanTurns(ctx, state) {
|
|
5488
|
+
const entries = collectStartupPending(ctx, state);
|
|
5489
|
+
if (!entries.length) return;
|
|
5490
|
+
await waitForRuntimeReady(ctx);
|
|
5491
|
+
const byContainer = new Map();
|
|
5492
|
+
for (const entry of entries) {
|
|
5493
|
+
if (!byContainer.has(entry.containerName)) byContainer.set(entry.containerName, []);
|
|
5494
|
+
byContainer.get(entry.containerName).push(entry);
|
|
5495
|
+
}
|
|
5496
|
+
for (const [containerName, list] of byContainer) {
|
|
5497
|
+
const live = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
|
|
5498
|
+
if (!live.length) {
|
|
5499
|
+
for (const entry of list) {
|
|
5500
|
+
try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_INTERRUPTED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
|
|
5501
|
+
}
|
|
5502
|
+
continue;
|
|
5503
|
+
}
|
|
5504
|
+
let checking = false;
|
|
5505
|
+
const timer = setInterval(async () => {
|
|
5506
|
+
if (checking) return;
|
|
5507
|
+
checking = true;
|
|
5508
|
+
try {
|
|
5509
|
+
const stillLive = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
|
|
5510
|
+
if (stillLive.length) return;
|
|
5511
|
+
clearInterval(timer);
|
|
5512
|
+
state.orphanTimers.delete(timer);
|
|
5513
|
+
for (const entry of list) {
|
|
5514
|
+
try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_FINISHED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
|
|
5515
|
+
}
|
|
5516
|
+
} finally {
|
|
5517
|
+
checking = false;
|
|
5518
|
+
}
|
|
5519
|
+
}, ctx.orphanPollMs || WEB_ORPHAN_POLL_MS);
|
|
5520
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
5521
|
+
state.orphanTimers.add(timer);
|
|
5522
|
+
}
|
|
5523
|
+
}
|
|
5524
|
+
|
|
5076
5525
|
// 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
|
|
5077
5526
|
// 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
|
|
5078
5527
|
// 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
|
|
5079
|
-
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
|
|
5528
|
+
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
|
|
5080
5529
|
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
5081
5530
|
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
5082
5531
|
return { busy: true };
|
|
@@ -5090,6 +5539,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5090
5539
|
userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
5091
5540
|
mode: 'agent',
|
|
5092
5541
|
pending: true,
|
|
5542
|
+
...(images.length ? { images } : {}),
|
|
5093
5543
|
...(clientMessageId ? { clientMessageId: clientMessageId } : {})
|
|
5094
5544
|
});
|
|
5095
5545
|
traceMessage = appendWebAgentTraceMessage(
|
|
@@ -5109,7 +5559,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5109
5559
|
|
|
5110
5560
|
let prepared = null;
|
|
5111
5561
|
try {
|
|
5112
|
-
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
5562
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
|
|
5113
5563
|
} catch (e) {
|
|
5114
5564
|
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
5115
5565
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
@@ -5271,9 +5721,23 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5271
5721
|
|
|
5272
5722
|
};
|
|
5273
5723
|
// 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
|
|
5274
|
-
|
|
5724
|
+
// 被拦的是 Agent 自己的模型域名:继续等只会白等到超时,立即中止本轮(不暂停排队,不是用户点的停止)
|
|
5725
|
+
const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }), {
|
|
5726
|
+
agentProgram: agentMeta.agentProgram,
|
|
5727
|
+
onModelDenied: host => {
|
|
5728
|
+
if (runState.modelDenied) return;
|
|
5729
|
+
runState.modelDenied = host;
|
|
5730
|
+
handleStreamEvent({ type: 'network_denied', host, model: true });
|
|
5731
|
+
runState.stopping = true;
|
|
5732
|
+
if (runState.process) terminateWebAgentRun(ctx, runState);
|
|
5733
|
+
}
|
|
5734
|
+
});
|
|
5735
|
+
let snapBefore = { skipped: 'unavailable' };
|
|
5275
5736
|
try {
|
|
5276
5737
|
if (deniedNotifier) deniedNotifier.start();
|
|
5738
|
+
snapBefore = await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} before`);
|
|
5739
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id,
|
|
5740
|
+
snapBefore.sha ? { snapBefore: snapBefore.sha } : { snapSkipped: snapBefore.skipped });
|
|
5277
5741
|
let result;
|
|
5278
5742
|
try {
|
|
5279
5743
|
result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
@@ -5291,14 +5755,21 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5291
5755
|
exitCode: result.exitCode,
|
|
5292
5756
|
interrupted: result.interrupted === true
|
|
5293
5757
|
});
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5758
|
+
const modelDeniedHost = runState.modelDenied || '';
|
|
5759
|
+
traceLines.push(modelDeniedHost
|
|
5760
|
+
? '[任务] 已中止:模型地址被网络策略拦截'
|
|
5761
|
+
: (result.interrupted === true
|
|
5762
|
+
? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
|
|
5763
|
+
: '[任务] 已完成'));
|
|
5764
|
+
const snapAfter = snapBefore.sha
|
|
5765
|
+
? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
|
|
5766
|
+
: { skipped: snapBefore.skipped };
|
|
5297
5767
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5298
5768
|
pending: false,
|
|
5299
5769
|
contextMode,
|
|
5300
5770
|
resumeAttempted,
|
|
5301
|
-
resumeSucceeded
|
|
5771
|
+
resumeSucceeded,
|
|
5772
|
+
...(snapAfter.sha ? { snapAfter: snapAfter.sha } : {})
|
|
5302
5773
|
});
|
|
5303
5774
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5304
5775
|
content: traceLines.join('\n'),
|
|
@@ -5312,26 +5783,30 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5312
5783
|
if (streamingReplyMessageId) {
|
|
5313
5784
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
|
|
5314
5785
|
}
|
|
5786
|
+
const finalResult = modelDeniedHost
|
|
5787
|
+
? { ...result, interrupted: true, output: `已中止:模型地址 ${modelDeniedHost} 被网络策略拦截。`, networkDenied: { host: modelDeniedHost, model: true } }
|
|
5788
|
+
: result;
|
|
5315
5789
|
const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
|
|
5316
5790
|
contextMode,
|
|
5317
5791
|
resumeAttempted,
|
|
5318
5792
|
resumeSucceeded,
|
|
5319
5793
|
resumeError,
|
|
5320
5794
|
engineSessionId
|
|
5321
|
-
},
|
|
5795
|
+
}, finalResult);
|
|
5322
5796
|
void refreshWebAgentTitle(ctx, state, sessionRef, agentMeta.agentProgram);
|
|
5323
5797
|
emitStreamEvent({
|
|
5324
5798
|
type: 'result',
|
|
5325
|
-
exitCode:
|
|
5326
|
-
output:
|
|
5799
|
+
exitCode: finalResult.exitCode,
|
|
5800
|
+
output: finalResult.output,
|
|
5801
|
+
...(finalResult.networkDenied ? { networkDenied: finalResult.networkDenied } : {}),
|
|
5327
5802
|
// 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
|
|
5328
5803
|
// 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
|
|
5329
5804
|
replyMessageId: replyMessage && replyMessage.id,
|
|
5330
5805
|
contextMode,
|
|
5331
5806
|
resumeAttempted,
|
|
5332
5807
|
resumeSucceeded,
|
|
5333
|
-
interrupted:
|
|
5334
|
-
},
|
|
5808
|
+
interrupted: finalResult.interrupted === true
|
|
5809
|
+
}, finalResult.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
|
|
5335
5810
|
} catch (e) {
|
|
5336
5811
|
ctx.logger.error('agent turn failed', {
|
|
5337
5812
|
session: turnSessionKey,
|
|
@@ -5339,11 +5814,15 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5339
5814
|
message: e && e.message ? e.message : 'Agent 执行失败'
|
|
5340
5815
|
});
|
|
5341
5816
|
traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
|
|
5817
|
+
const failedSnapAfter = snapBefore.sha
|
|
5818
|
+
? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
|
|
5819
|
+
: { skipped: snapBefore.skipped };
|
|
5342
5820
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5343
5821
|
pending: false,
|
|
5344
5822
|
contextMode,
|
|
5345
5823
|
resumeAttempted,
|
|
5346
|
-
resumeSucceeded
|
|
5824
|
+
resumeSucceeded,
|
|
5825
|
+
...(failedSnapAfter.sha ? { snapAfter: failedSnapAfter.sha } : {})
|
|
5347
5826
|
});
|
|
5348
5827
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5349
5828
|
content: traceLines.join('\n'),
|
|
@@ -5365,7 +5844,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5365
5844
|
clearInterval(heartbeatTimer);
|
|
5366
5845
|
clearTimeout(runState.stepTimer);
|
|
5367
5846
|
// 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
|
|
5368
|
-
const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
|
|
5847
|
+
const hasNext = runState.userStopped !== true && !runState.modelDenied && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
|
|
5369
5848
|
if (hasNext && !res.writableEnded && !res.destroyed) {
|
|
5370
5849
|
// 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
|
|
5371
5850
|
sendNdjson(res, { type: 'queue_next' });
|
|
@@ -5416,6 +5895,15 @@ async function inspectSetupImage(ctx, state) {
|
|
|
5416
5895
|
}
|
|
5417
5896
|
}
|
|
5418
5897
|
|
|
5898
|
+
// 镜像里装了哪些 Agent CLI;镜像没就绪或探测失败返回 null(未知,向导照常可选)
|
|
5899
|
+
async function probeSetupImageAgents(ctx, state) {
|
|
5900
|
+
if (!ctx.dockerExecAsync) return null;
|
|
5901
|
+
const image = await inspectSetupImage(ctx, state);
|
|
5902
|
+
if (image.status !== 'ready') return null;
|
|
5903
|
+
if (!state.imageAgentProbe) state.imageAgentProbe = createImageAgentProbe(ctx.dockerExecAsync);
|
|
5904
|
+
return state.imageAgentProbe(image.name);
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5419
5907
|
// 在一次性容器里用所选 Agent 发一次最小请求;容器无论成败都会删除。
|
|
5420
5908
|
async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
5421
5909
|
const secrets = Object.values(envMap);
|
|
@@ -5429,7 +5917,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
5429
5917
|
if (!template) {
|
|
5430
5918
|
return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
|
|
5431
5919
|
}
|
|
5432
|
-
const agentCommand = renderAgentPromptCommand(template, SETUP_TEST_PROMPT);
|
|
5920
|
+
const agentCommand = withCodexBaseUrl(renderAgentPromptCommand(template, SETUP_TEST_PROMPT));
|
|
5433
5921
|
const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
|
|
5434
5922
|
|
|
5435
5923
|
const workpath = getSetupWorkpath(state);
|
|
@@ -6541,11 +7029,35 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6541
7029
|
sendJson(res, 200, await buildSetupStatus(ctx, state));
|
|
6542
7030
|
}
|
|
6543
7031
|
},
|
|
7032
|
+
{
|
|
7033
|
+
// 本机已有的镜像(系统设置「基础」里选镜像名 / 版本用):只列版本号符合 x.y.z-后缀 的,按镜像名分组
|
|
7034
|
+
method: 'GET',
|
|
7035
|
+
match: currentPath => currentPath === '/api/images' ? [] : null,
|
|
7036
|
+
handler: async () => {
|
|
7037
|
+
const defaults = { imageName: ctx.imageName || '', imageVersion: ctx.imageVersion || '' };
|
|
7038
|
+
if (!ctx.dockerExecAsync || !ctx.runtimeState || ctx.runtimeState.status !== 'ready') {
|
|
7039
|
+
sendJson(res, 200, { defaults, images: [] });
|
|
7040
|
+
return;
|
|
7041
|
+
}
|
|
7042
|
+
let output = '';
|
|
7043
|
+
try {
|
|
7044
|
+
output = String(await ctx.dockerExecAsync(['images', '-a', '--format', '{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}'], { timeout: 15000 }) || '');
|
|
7045
|
+
} catch (e) {
|
|
7046
|
+
output = '';
|
|
7047
|
+
}
|
|
7048
|
+
sendJson(res, 200, { defaults, images: parseWebImageList(output) });
|
|
7049
|
+
}
|
|
7050
|
+
},
|
|
6544
7051
|
{
|
|
6545
7052
|
method: 'GET',
|
|
6546
7053
|
match: currentPath => currentPath === '/api/setup/agents' ? [] : null,
|
|
6547
7054
|
handler: async () => {
|
|
6548
|
-
|
|
7055
|
+
const installed = await probeSetupImageAgents(ctx, state);
|
|
7056
|
+
const agents = listSetupAgents().map(agent => ({
|
|
7057
|
+
...agent,
|
|
7058
|
+
available: installed ? installed.includes(agent.id) : null
|
|
7059
|
+
}));
|
|
7060
|
+
sendJson(res, 200, { agents, mirrorPresets: listMirrorPresets() });
|
|
6549
7061
|
}
|
|
6550
7062
|
},
|
|
6551
7063
|
{
|
|
@@ -7100,6 +7612,69 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7100
7612
|
sendJson(res, 200, payload);
|
|
7101
7613
|
}
|
|
7102
7614
|
},
|
|
7615
|
+
{
|
|
7616
|
+
// 聊天里的图片:原始请求体,类型只收 png/jpeg/gif/webp,单张 10MB
|
|
7617
|
+
method: 'POST',
|
|
7618
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/uploads$/),
|
|
7619
|
+
handler: async match => {
|
|
7620
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7621
|
+
if (!sessionRef) {
|
|
7622
|
+
return;
|
|
7623
|
+
}
|
|
7624
|
+
const ext = webUploads.extForContentType(req.headers['content-type']);
|
|
7625
|
+
if (!ext) {
|
|
7626
|
+
sendJson(res, 415, { error: '只支持 png、jpeg、gif、webp 图片' });
|
|
7627
|
+
return;
|
|
7628
|
+
}
|
|
7629
|
+
const declared = Number(req.headers['content-length']);
|
|
7630
|
+
if (Number.isFinite(declared) && declared > webUploads.MAX_IMAGE_BYTES) {
|
|
7631
|
+
sendJson(res, 413, { error: '图片不能超过 10MB' });
|
|
7632
|
+
return;
|
|
7633
|
+
}
|
|
7634
|
+
if (!ctx.containerExists(sessionRef.containerName)) {
|
|
7635
|
+
sendJson(res, 404, { error: `容器不存在: ${sessionRef.containerName}` });
|
|
7636
|
+
return;
|
|
7637
|
+
}
|
|
7638
|
+
const homeDir = ctx.homeDir || os.homedir();
|
|
7639
|
+
const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
|
|
7640
|
+
if (!id || !containerState.stateExists(homeDir, id)) {
|
|
7641
|
+
sendJson(res, 409, { error: '这个容器太旧,不支持发图片,重建后可用' });
|
|
7642
|
+
return;
|
|
7643
|
+
}
|
|
7644
|
+
const saved = await webUploads.saveUpload(req, path.join(containerState.paths(homeDir, id).box, 'uploads'), ext);
|
|
7645
|
+
if (!saved.ok) {
|
|
7646
|
+
sendJson(res, saved.status, { error: saved.error });
|
|
7647
|
+
return;
|
|
7648
|
+
}
|
|
7649
|
+
sendJson(res, 200, { path: `${webUploads.CONTAINER_UPLOAD_DIR}/${saved.name}` });
|
|
7650
|
+
}
|
|
7651
|
+
},
|
|
7652
|
+
{
|
|
7653
|
+
method: 'GET',
|
|
7654
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/stat$/),
|
|
7655
|
+
handler: async match => {
|
|
7656
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7657
|
+
if (!sessionRef) {
|
|
7658
|
+
return;
|
|
7659
|
+
}
|
|
7660
|
+
const requestUrl = new URL(req.url || '/api/sessions/x/fs/stat', 'http://localhost');
|
|
7661
|
+
const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
|
|
7662
|
+
// 点一下文字不该拉起容器:没在运行就当作不存在
|
|
7663
|
+
if (!targetPath || !ctx.containerExists(sessionRef.containerName) || ctx.getContainerStatus(sessionRef.containerName) !== 'running') {
|
|
7664
|
+
sendJson(res, 200, { exists: false });
|
|
7665
|
+
return;
|
|
7666
|
+
}
|
|
7667
|
+
let payload = null;
|
|
7668
|
+
try {
|
|
7669
|
+
payload = await execJsonCommandInWebContainer(ctx, sessionRef.containerName, buildContainerPathExistsCommand(targetPath));
|
|
7670
|
+
} catch (e) {
|
|
7671
|
+
payload = null;
|
|
7672
|
+
}
|
|
7673
|
+
sendJson(res, 200, payload && payload.exists === true
|
|
7674
|
+
? { exists: true, type: payload.type }
|
|
7675
|
+
: { exists: false });
|
|
7676
|
+
}
|
|
7677
|
+
},
|
|
7103
7678
|
{
|
|
7104
7679
|
method: 'GET',
|
|
7105
7680
|
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/read$/),
|
|
@@ -7320,6 +7895,130 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7320
7895
|
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
|
|
7321
7896
|
}
|
|
7322
7897
|
},
|
|
7898
|
+
{
|
|
7899
|
+
// 按轮看改动:比较某一轮前后两个快照(提交号必须是 40 位小写十六进制)
|
|
7900
|
+
method: 'GET',
|
|
7901
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/changes$/),
|
|
7902
|
+
handler: async match => {
|
|
7903
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7904
|
+
if (!sessionRef) {
|
|
7905
|
+
return;
|
|
7906
|
+
}
|
|
7907
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
7908
|
+
const from = String(requestUrl.searchParams.get('from') || '');
|
|
7909
|
+
const to = String(requestUrl.searchParams.get('to') || '');
|
|
7910
|
+
if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
|
|
7911
|
+
sendJson(res, 400, { error: '快照号不合法' });
|
|
7912
|
+
return;
|
|
7913
|
+
}
|
|
7914
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
7915
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(resolveWebSessionWorkDir(state, sessionRef), from, to), 30000);
|
|
7916
|
+
sendJson(res, 200, webSnapshots.parseRoundChangesOutput(out));
|
|
7917
|
+
}
|
|
7918
|
+
},
|
|
7919
|
+
{
|
|
7920
|
+
method: 'GET',
|
|
7921
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/diff$/),
|
|
7922
|
+
handler: async match => {
|
|
7923
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7924
|
+
if (!sessionRef) {
|
|
7925
|
+
return;
|
|
7926
|
+
}
|
|
7927
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
7928
|
+
const from = String(requestUrl.searchParams.get('from') || '');
|
|
7929
|
+
const to = String(requestUrl.searchParams.get('to') || '');
|
|
7930
|
+
const filePath = String(requestUrl.searchParams.get('path') || '');
|
|
7931
|
+
if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
|
|
7932
|
+
sendJson(res, 400, { error: '快照号不合法' });
|
|
7933
|
+
return;
|
|
7934
|
+
}
|
|
7935
|
+
if (!gitChanges.isAcceptableDiffPath(filePath)) {
|
|
7936
|
+
sendJson(res, 400, { error: '文件路径不合法' });
|
|
7937
|
+
return;
|
|
7938
|
+
}
|
|
7939
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
7940
|
+
const workDir = resolveWebSessionWorkDir(state, sessionRef);
|
|
7941
|
+
// 只给这两个快照之间出现过的文件看 diff:路径由服务端重新取一次核对,不信任请求
|
|
7942
|
+
const listed = webSnapshots.parseRoundChangesOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(workDir, from, to), 30000));
|
|
7943
|
+
if (!listed.files.some(file => file.path === filePath)) {
|
|
7944
|
+
sendJson(res, 404, { error: '该文件不在这一轮的改动里' });
|
|
7945
|
+
return;
|
|
7946
|
+
}
|
|
7947
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundDiffCommand(workDir, from, to, filePath), 30000);
|
|
7948
|
+
sendJson(res, 200, { path: filePath, ...webSnapshots.parseRoundDiffOutput(out) });
|
|
7949
|
+
}
|
|
7950
|
+
},
|
|
7951
|
+
{
|
|
7952
|
+
// 回退前预览:会改 / 删 / 恢复哪些文件(会先拍一张“现在”的快照,不改任何文件)
|
|
7953
|
+
method: 'POST',
|
|
7954
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind\/preview$/),
|
|
7955
|
+
handler: async match => {
|
|
7956
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7957
|
+
if (!sessionRef) {
|
|
7958
|
+
return;
|
|
7959
|
+
}
|
|
7960
|
+
const payload = await readJsonBody(req);
|
|
7961
|
+
const target = findWebRewindTarget(state, sessionRef, payload.messageId);
|
|
7962
|
+
if (target.error) {
|
|
7963
|
+
sendJson(res, target.status, { error: target.error });
|
|
7964
|
+
return;
|
|
7965
|
+
}
|
|
7966
|
+
const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
|
|
7967
|
+
target.history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
|
|
7968
|
+
)).agentProgram;
|
|
7969
|
+
const result = { agentProgram: program, precise: program === 'claude', files: null };
|
|
7970
|
+
if (webSnapshots.isSnapId(target.message.snapBefore)) {
|
|
7971
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestorePreviewCommand(resolveWebSessionWorkDir(state, sessionRef), target.message.snapBefore), 30000);
|
|
7972
|
+
const preview = webSnapshots.parseRestorePreviewOutput(out);
|
|
7973
|
+
result.files = preview.ok ? preview : { ok: false, reason: preview.reason };
|
|
7974
|
+
}
|
|
7975
|
+
sendJson(res, 200, result);
|
|
7976
|
+
}
|
|
7977
|
+
},
|
|
7978
|
+
{
|
|
7979
|
+
// 回退:scope = both(对话和文件)/ conversation(只对话)/ files(只文件)
|
|
7980
|
+
method: 'POST',
|
|
7981
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind$/),
|
|
7982
|
+
handler: async match => {
|
|
7983
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7984
|
+
if (!sessionRef) {
|
|
7985
|
+
return;
|
|
7986
|
+
}
|
|
7987
|
+
const payload = await readJsonBody(req);
|
|
7988
|
+
const scope = String(payload.scope || '');
|
|
7989
|
+
if (!['both', 'conversation', 'files'].includes(scope)) {
|
|
7990
|
+
sendJson(res, 400, { error: 'scope 只能是 both、conversation 或 files' });
|
|
7991
|
+
return;
|
|
7992
|
+
}
|
|
7993
|
+
const outcome = await rewindWebSession(ctx, state, sessionRef, payload.messageId, scope);
|
|
7994
|
+
if (outcome.error) {
|
|
7995
|
+
sendJson(res, outcome.status, { error: outcome.error });
|
|
7996
|
+
return;
|
|
7997
|
+
}
|
|
7998
|
+
sendJson(res, 200, outcome);
|
|
7999
|
+
}
|
|
8000
|
+
},
|
|
8001
|
+
{
|
|
8002
|
+
method: 'POST',
|
|
8003
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/hide$/),
|
|
8004
|
+
handler: async match => {
|
|
8005
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8006
|
+
if (!sessionRef) {
|
|
8007
|
+
return;
|
|
8008
|
+
}
|
|
8009
|
+
const payload = await readJsonBody(req);
|
|
8010
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
8011
|
+
sendJson(res, 409, { error: '任务进行中,暂时不能隐藏' });
|
|
8012
|
+
return;
|
|
8013
|
+
}
|
|
8014
|
+
const changed = setWebSessionTurnHidden(state.webHistoryDir, sessionRef, String(payload.messageId || ''), payload.hidden !== false);
|
|
8015
|
+
if (!changed) {
|
|
8016
|
+
sendJson(res, 404, { error: '没有找到这一组' });
|
|
8017
|
+
return;
|
|
8018
|
+
}
|
|
8019
|
+
sendJson(res, 200, { ok: true, count: changed });
|
|
8020
|
+
}
|
|
8021
|
+
},
|
|
7323
8022
|
{
|
|
7324
8023
|
// “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
|
|
7325
8024
|
method: 'GET',
|
|
@@ -7642,6 +8341,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7642
8341
|
}
|
|
7643
8342
|
|
|
7644
8343
|
const payload = await readJsonBody(req);
|
|
8344
|
+
let streamImages;
|
|
8345
|
+
try {
|
|
8346
|
+
streamImages = webUploads.parseImages(payload.images);
|
|
8347
|
+
} catch (e) {
|
|
8348
|
+
sendJson(res, 400, { error: e.message });
|
|
8349
|
+
return;
|
|
8350
|
+
}
|
|
7645
8351
|
const prompt = (payload.prompt || '').trim();
|
|
7646
8352
|
if (!prompt) {
|
|
7647
8353
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
@@ -7654,7 +8360,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7654
8360
|
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
|
|
7655
8361
|
return;
|
|
7656
8362
|
}
|
|
7657
|
-
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
|
|
8363
|
+
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages);
|
|
7658
8364
|
if (turn.busy) {
|
|
7659
8365
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
7660
8366
|
} else if (turn.error) {
|
|
@@ -7671,6 +8377,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7671
8377
|
return;
|
|
7672
8378
|
}
|
|
7673
8379
|
const payload = await readJsonBody(req);
|
|
8380
|
+
let queueImages;
|
|
8381
|
+
try {
|
|
8382
|
+
queueImages = webUploads.parseImages(payload.images);
|
|
8383
|
+
} catch (e) {
|
|
8384
|
+
sendJson(res, 400, { error: e.message });
|
|
8385
|
+
return;
|
|
8386
|
+
}
|
|
7674
8387
|
const text = (payload.prompt || '').trim();
|
|
7675
8388
|
if (!text) {
|
|
7676
8389
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
@@ -7696,7 +8409,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7696
8409
|
// 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
|
|
7697
8410
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
7698
8411
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
7699
|
-
const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
|
|
8412
|
+
const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when, images: queueImages });
|
|
7700
8413
|
if (!added.ok) {
|
|
7701
8414
|
sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
|
|
7702
8415
|
return;
|
|
@@ -7975,6 +8688,7 @@ async function startWebServer(options) {
|
|
|
7975
8688
|
? options.agentRawOutputMaxChars
|
|
7976
8689
|
: AGENT_RAW_OUTPUT_MAX_CHARS,
|
|
7977
8690
|
// 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
|
|
8691
|
+
orphanPollMs: Number.isFinite(options.orphanPollMs) && options.orphanPollMs > 0 ? options.orphanPollMs : WEB_ORPHAN_POLL_MS,
|
|
7978
8692
|
agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
|
|
7979
8693
|
? options.agentStepFallbackMs
|
|
7980
8694
|
: agentQueue.STEP_FALLBACK_MS,
|
|
@@ -7993,7 +8707,15 @@ async function startWebServer(options) {
|
|
|
7993
8707
|
const state = {
|
|
7994
8708
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
7995
8709
|
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
7996
|
-
|
|
8710
|
+
orphanTimers: new Set(),
|
|
8711
|
+
authStore: createAuthStore({
|
|
8712
|
+
file: options.authSessionsFile || '',
|
|
8713
|
+
user: ctx.authUser,
|
|
8714
|
+
pass: ctx.authPass,
|
|
8715
|
+
ttlMs: WEB_AUTH_TTL_SECONDS * 1000,
|
|
8716
|
+
now: Date.now,
|
|
8717
|
+
warn: message => ctx.logger.warn(message)
|
|
8718
|
+
}),
|
|
7997
8719
|
terminalSessions: new Map(),
|
|
7998
8720
|
agentRuns: new Map(),
|
|
7999
8721
|
// imageRef -> { status: 'pulling'|'failed', message, promise }
|
|
@@ -8206,6 +8928,7 @@ async function startWebServer(options) {
|
|
|
8206
8928
|
});
|
|
8207
8929
|
|
|
8208
8930
|
startStaleSetupCleanup(ctx);
|
|
8931
|
+
state.orphanReconcileReady = reconcileOrphanTurns(ctx, state).catch(e => ctx.logger.warn('orphan reconcile failed', { message: e && e.message }));
|
|
8209
8932
|
startContainerNetworkWatch(ctx, state);
|
|
8210
8933
|
if (ctx.autoPullImage) {
|
|
8211
8934
|
startWebImagePrepare(ctx, state);
|
|
@@ -8219,6 +8942,9 @@ async function startWebServer(options) {
|
|
|
8219
8942
|
close: () => new Promise(resolve => {
|
|
8220
8943
|
ctx.logger.info('web server closing');
|
|
8221
8944
|
flushWebSessionHistories();
|
|
8945
|
+
state.authStore.close();
|
|
8946
|
+
state.orphanTimers.forEach(timer => clearInterval(timer));
|
|
8947
|
+
state.orphanTimers.clear();
|
|
8222
8948
|
if (state.networkWatch) {
|
|
8223
8949
|
state.networkWatch.stop();
|
|
8224
8950
|
state.networkWatch = null;
|