@xcanwin/manyoyo 8.6.11 → 8.7.0
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 +16 -14
- package/docker/manyoyo.Dockerfile +1 -1
- package/docker/res/claude/settings.json +20 -0
- package/lib/agent-adapters/index.js +18 -1
- package/lib/container-network.js +49 -10
- package/lib/egress-sidecar.js +19 -5
- package/lib/image-agents.js +45 -0
- package/lib/init-config.js +2 -1
- package/lib/name-template.js +52 -0
- package/lib/network-endpoints.js +56 -1
- package/lib/plugin/playwright.js +12 -5
- package/lib/provider-presets.js +86 -0
- package/lib/runtime-resolver.js +13 -1
- 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 +94 -94
- package/lib/web/rewind.js +75 -0
- package/lib/web/server.js +971 -75
- package/lib/web/snapshots.js +245 -0
- package/lib/web/storage.js +444 -0
- package/lib/web/uploads.js +114 -0
- package/package.json +8 -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');
|
|
@@ -34,10 +34,17 @@ const { estimateContainerCapacity } = require('../capacity');
|
|
|
34
34
|
const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
|
|
35
35
|
const { findValueRangeByPath, applyTextReplacements, upsertValueByPath } = require('../json5-text-edit');
|
|
36
36
|
const { resolveRuntimeConfig } = require('../runtime-resolver');
|
|
37
|
+
const { resolveQuickChatPath, expandTemplate } = require('../name-template');
|
|
37
38
|
const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
38
39
|
const { ensureImagePresent, pullImageProcess } = require('../image-pull');
|
|
39
40
|
const { waitForImport } = require('../offline-import');
|
|
40
41
|
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
|
|
42
|
+
const { createImageAgentProbe } = require('../image-agents');
|
|
43
|
+
const { createAuthStore } = require('./auth-store');
|
|
44
|
+
const webUploads = require('./uploads');
|
|
45
|
+
const webStorage = require('./storage');
|
|
46
|
+
const webSnapshots = require('./snapshots');
|
|
47
|
+
const webRewind = require('./rewind');
|
|
41
48
|
const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
|
|
42
49
|
const { applyDoctorFixes } = require('../doctor');
|
|
43
50
|
const { writeConfigFileSecure } = require('../secure-file');
|
|
@@ -52,7 +59,7 @@ const {
|
|
|
52
59
|
resolveAgentPromptCommandTemplate,
|
|
53
60
|
buildAgentResumeCommand
|
|
54
61
|
} = require('../agent-resume');
|
|
55
|
-
const { resolveYoloCommand: resolveYoloCommandFromAdapters } = require('../agent-adapters');
|
|
62
|
+
const { resolveYoloCommand: resolveYoloCommandFromAdapters, withCodexBaseUrl } = require('../agent-adapters');
|
|
56
63
|
const {
|
|
57
64
|
createControlEvent,
|
|
58
65
|
validateControlEvent,
|
|
@@ -229,7 +236,9 @@ function createEmptyWebAgentSession(agentId, agentName) {
|
|
|
229
236
|
title: '',
|
|
230
237
|
firstPrompt: '',
|
|
231
238
|
pinned: false,
|
|
232
|
-
slashCommands: null
|
|
239
|
+
slashCommands: null,
|
|
240
|
+
// 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
|
|
241
|
+
rewindToUuid: ''
|
|
233
242
|
};
|
|
234
243
|
}
|
|
235
244
|
|
|
@@ -287,7 +296,8 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
287
296
|
title: sessionMeta.normalizeTitle(source.title),
|
|
288
297
|
firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
|
|
289
298
|
pinned: source.pinned === true,
|
|
290
|
-
slashCommands: normalizeStoredSlashCommands(source.slashCommands)
|
|
299
|
+
slashCommands: normalizeStoredSlashCommands(source.slashCommands),
|
|
300
|
+
rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
|
|
291
301
|
};
|
|
292
302
|
}
|
|
293
303
|
|
|
@@ -730,6 +740,53 @@ function removeWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messa
|
|
|
730
740
|
return removed;
|
|
731
741
|
}
|
|
732
742
|
|
|
743
|
+
// 删掉某条消息及之后的所有消息(回退对话)。改动的是稳定前缀,必须换新 epoch,增量同步据此让前端整页重置
|
|
744
|
+
function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
|
|
745
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
746
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
747
|
+
if (!agentSession) {
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
const index = agentSession.messages.findIndex(item => item && String(item.id || '') === String(messageId));
|
|
751
|
+
if (index === -1) {
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
const removed = agentSession.messages.splice(index);
|
|
755
|
+
agentSession.epoch = messagesView.createEpoch();
|
|
756
|
+
const timestamp = new Date().toISOString();
|
|
757
|
+
agentSession.updatedAt = timestamp;
|
|
758
|
+
history.updatedAt = timestamp;
|
|
759
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
760
|
+
return removed;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// 从页面隐藏 / 取消隐藏某一组(用户消息 + 它的执行过程 + 回复):只改展示,agent 仍记得
|
|
764
|
+
function setWebSessionTurnHidden(webHistoryDir, sessionRef, userMessageId, hidden) {
|
|
765
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
766
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
767
|
+
if (!agentSession) {
|
|
768
|
+
return 0;
|
|
769
|
+
}
|
|
770
|
+
const start = agentSession.messages.findIndex(item => item && String(item.id || '') === String(userMessageId) && item.role === 'user');
|
|
771
|
+
if (start === -1) {
|
|
772
|
+
return 0;
|
|
773
|
+
}
|
|
774
|
+
let end = start + 1;
|
|
775
|
+
while (end < agentSession.messages.length && agentSession.messages[end].role !== 'user') {
|
|
776
|
+
end += 1;
|
|
777
|
+
}
|
|
778
|
+
for (let i = start; i < end; i += 1) {
|
|
779
|
+
if (hidden) agentSession.messages[i].hidden = true;
|
|
780
|
+
else delete agentSession.messages[i].hidden;
|
|
781
|
+
}
|
|
782
|
+
agentSession.epoch = messagesView.createEpoch();
|
|
783
|
+
const timestamp = new Date().toISOString();
|
|
784
|
+
agentSession.updatedAt = timestamp;
|
|
785
|
+
history.updatedAt = timestamp;
|
|
786
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
787
|
+
return end - start;
|
|
788
|
+
}
|
|
789
|
+
|
|
733
790
|
function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
|
|
734
791
|
const history = loadWebSessionHistory(webHistoryDir, containerName);
|
|
735
792
|
history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
|
|
@@ -960,20 +1017,24 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
960
1017
|
const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
|
|
961
1018
|
// 环境说明:模板里已经写了 developer_instructions 就以用户的为准
|
|
962
1019
|
const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
|
|
1020
|
+
// 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
|
|
1021
|
+
const images = options && Array.isArray(options.images) ? options.images : [];
|
|
1022
|
+
const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
|
|
963
1023
|
const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
|
|
964
1024
|
? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
|
|
965
1025
|
: '';
|
|
966
1026
|
if (sessionId) {
|
|
967
1027
|
const promptIndex = suffix.indexOf('{prompt}');
|
|
968
1028
|
const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
|
|
969
|
-
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${resumeSuffix}`;
|
|
1029
|
+
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
|
|
970
1030
|
} else {
|
|
971
|
-
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${suffix}`;
|
|
1031
|
+
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
|
|
972
1032
|
}
|
|
973
1033
|
}
|
|
974
|
-
|
|
1034
|
+
// 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
|
|
1035
|
+
return withCodexBaseUrl(codexTemplate === templateText
|
|
975
1036
|
? renderAgentPromptCommand(templateText, prompt)
|
|
976
|
-
: renderAgentPromptCommand(codexTemplate, prompt);
|
|
1037
|
+
: renderAgentPromptCommand(codexTemplate, prompt));
|
|
977
1038
|
}
|
|
978
1039
|
|
|
979
1040
|
function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
@@ -1010,6 +1071,10 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
1010
1071
|
if (sessionId) {
|
|
1011
1072
|
flagSpecs.push({ flag: `-r ${sessionId}`, pattern: /(?:^|\s)-r(?:\s|$)/ });
|
|
1012
1073
|
}
|
|
1074
|
+
const resumeAtUuid = options && typeof options.resumeAtUuid === 'string' && webRewind.UUID_PATTERN.test(options.resumeAtUuid) ? options.resumeAtUuid : '';
|
|
1075
|
+
if (sessionId && resumeAtUuid) {
|
|
1076
|
+
flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
|
|
1077
|
+
}
|
|
1013
1078
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
1014
1079
|
if (model) {
|
|
1015
1080
|
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
@@ -2323,7 +2388,7 @@ function prepareCodexTraceEvent(payload) {
|
|
|
2323
2388
|
});
|
|
2324
2389
|
}
|
|
2325
2390
|
|
|
2326
|
-
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
2391
|
+
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
|
|
2327
2392
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2328
2393
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
2329
2394
|
const containerMap = listWebManyoyoContainers(ctx);
|
|
@@ -2377,9 +2442,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
|
2377
2442
|
}
|
|
2378
2443
|
}
|
|
2379
2444
|
|
|
2445
|
+
// Codex 用 --image 传图;其余 Agent 在提示词末尾写路径,让它们用读文件的工具去看
|
|
2446
|
+
const imagesByFlag = agentMeta.agentProgram === 'codex';
|
|
2447
|
+
const promptWithImages = imagesByFlag ? prompt : webUploads.appendImageNote(prompt, images);
|
|
2380
2448
|
const effectivePrompt = resumeSucceeded
|
|
2381
|
-
?
|
|
2382
|
-
: buildAgentPromptWithHistory(agentSession,
|
|
2449
|
+
? promptWithImages
|
|
2450
|
+
: buildAgentPromptWithHistory(agentSession, promptWithImages);
|
|
2383
2451
|
// 环境说明每次执行时现读配置:改了设置不用重建容器;运行配置名记在创建时的快照里(旧会话没有,用全局配置)
|
|
2384
2452
|
const configSnapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
2385
2453
|
const runName = history.runtimeSnapshot && typeof history.runtimeSnapshot.run === 'string' ? history.runtimeSnapshot.run : '';
|
|
@@ -2387,6 +2455,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
|
2387
2455
|
sessionId: sessionIdForRun,
|
|
2388
2456
|
model: agentSession.model || '',
|
|
2389
2457
|
forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
|
|
2458
|
+
images: imagesByFlag ? images : [],
|
|
2459
|
+
resumeAtUuid: agentSession.rewindToUuid || '',
|
|
2390
2460
|
instructions: buildAgentInstructions(
|
|
2391
2461
|
configSnapshot.parseError ? {} : configSnapshot.parsed,
|
|
2392
2462
|
runName,
|
|
@@ -2418,9 +2488,11 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
|
|
|
2418
2488
|
resumeAttempted: meta.resumeAttempted,
|
|
2419
2489
|
resumeSucceeded: meta.resumeSucceeded,
|
|
2420
2490
|
interrupted: result.interrupted === true,
|
|
2491
|
+
...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
|
|
2421
2492
|
...(turnUsage ? { usage: turnUsage } : {})
|
|
2422
2493
|
});
|
|
2423
2494
|
const patch = {
|
|
2495
|
+
rewindToUuid: '',
|
|
2424
2496
|
lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
|
|
2425
2497
|
lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
|
|
2426
2498
|
lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
|
|
@@ -2442,6 +2514,129 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
|
|
|
2442
2514
|
return replyMessage;
|
|
2443
2515
|
}
|
|
2444
2516
|
|
|
2517
|
+
// 每轮前后各拍一次工作目录快照(影子 git,见 lib/web/snapshots.js);失败 / 太大不影响这一轮,只是这一轮不能回退文件
|
|
2518
|
+
// 在容器里跑一段 bash 脚本并取 stdout(脚本里的路径 / 提交号都已转义或校验)。
|
|
2519
|
+
// 用 `bash -c` 而不是 execCommandInWebContainer 的 `/bin/bash -lc`:不走登录 shell,更快;也让测试里按 /bin/bash 识别 Agent 运行的假 docker 不会把它误当成一轮对话
|
|
2520
|
+
function runWebContainerScript(ctx, containerName, script, timeoutMs = 60000) {
|
|
2521
|
+
return new Promise(resolve => {
|
|
2522
|
+
let child;
|
|
2523
|
+
try {
|
|
2524
|
+
child = spawnContainerExec(ctx, containerName, { withEnv: false, command: ['bash', '-c', script] }, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
2525
|
+
} catch (e) {
|
|
2526
|
+
resolve('');
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
let out = '';
|
|
2530
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
2531
|
+
child.stdout.setEncoding('utf8');
|
|
2532
|
+
child.stdout.on('data', chunk => { if (out.length < 4 * 1024 * 1024) out += chunk; });
|
|
2533
|
+
child.once('close', () => { clearTimeout(timer); resolve(out); });
|
|
2534
|
+
child.once('error', () => { clearTimeout(timer); resolve(out); });
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
async function takeWebSnapshot(ctx, state, sessionRef, label) {
|
|
2539
|
+
const workDir = resolveWebSessionWorkDir(state, sessionRef);
|
|
2540
|
+
if (!workDir || workDir === '/') {
|
|
2541
|
+
return { skipped: 'unavailable' };
|
|
2542
|
+
}
|
|
2543
|
+
try {
|
|
2544
|
+
return webSnapshots.parseSnapshotOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildSnapshotCommand(workDir, label), 15000));
|
|
2545
|
+
} catch (e) {
|
|
2546
|
+
return { skipped: 'unavailable' };
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// 回退的目标必须是一条已经结束的 Agent 用户消息,且这个容器里没有任务在跑
|
|
2551
|
+
function findWebRewindTarget(state, sessionRef, messageId) {
|
|
2552
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
2553
|
+
return { status: 409, error: '任务进行中,暂时不能回退' };
|
|
2554
|
+
}
|
|
2555
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2556
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
2557
|
+
const id = String(messageId || '');
|
|
2558
|
+
const message = agentSession && agentSession.messages.find(item => item && String(item.id || '') === id);
|
|
2559
|
+
if (!message || message.role !== 'user' || message.mode === 'command') {
|
|
2560
|
+
return { status: 404, error: '没有找到这条提示词' };
|
|
2561
|
+
}
|
|
2562
|
+
if (message.pending === true) {
|
|
2563
|
+
return { status: 409, error: '这一轮还在进行中' };
|
|
2564
|
+
}
|
|
2565
|
+
return { history, agentSession, message };
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
async function rewindWebSession(ctx, state, sessionRef, messageId, scope) {
|
|
2569
|
+
const target = findWebRewindTarget(state, sessionRef, messageId);
|
|
2570
|
+
if (target.error) {
|
|
2571
|
+
return target;
|
|
2572
|
+
}
|
|
2573
|
+
// 回退期间占住这个容器的运行锁:恢复脚本最长跑 2 分钟,这段时间里不能让新的一轮开跑(文件会被还原、新消息会被删)
|
|
2574
|
+
const runState = reserveWebAgentRun(state, sessionRef);
|
|
2575
|
+
try {
|
|
2576
|
+
return await rewindWebSessionLocked(ctx, state, sessionRef, scope, target);
|
|
2577
|
+
} finally {
|
|
2578
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
|
|
2583
|
+
const { history, agentSession, message } = target;
|
|
2584
|
+
let filesRestored = false;
|
|
2585
|
+
if (scope !== 'conversation') {
|
|
2586
|
+
if (!webSnapshots.isSnapId(message.snapBefore)) {
|
|
2587
|
+
return { status: 409, error: '这一轮没有文件快照,不能回退文件' };
|
|
2588
|
+
}
|
|
2589
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
2590
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestoreCommand(resolveWebSessionWorkDir(state, sessionRef), message.snapBefore), 120000);
|
|
2591
|
+
const restored = webSnapshots.parseRestoreOutput(out);
|
|
2592
|
+
if (!restored.ok) {
|
|
2593
|
+
// 先要给“现在”拍一张快照(让回退本身能撤销);有超过 20MB 的大文件或快照仓库超过 1GB 时拍不了,宁可不回退
|
|
2594
|
+
if (restored.reason === 'too-large') {
|
|
2595
|
+
return { status: 409, error: '工作目录里有超过 20MB 的大文件(或快照已超过 1GB),没法先给当前状态留快照,所以不能回退文件;只回退对话没有这个限制' };
|
|
2596
|
+
}
|
|
2597
|
+
return { status: 500, error: '恢复文件失败,工作目录没有改动' };
|
|
2598
|
+
}
|
|
2599
|
+
filesRestored = true;
|
|
2600
|
+
}
|
|
2601
|
+
let conversationMode = '';
|
|
2602
|
+
if (scope !== 'files') {
|
|
2603
|
+
const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
|
|
2604
|
+
history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
|
|
2605
|
+
)).agentProgram;
|
|
2606
|
+
let engineSessionId = agentSession.engineSessionId || '';
|
|
2607
|
+
let rewindToUuid = '';
|
|
2608
|
+
if (!engineSessionId) {
|
|
2609
|
+
conversationMode = 'precise';
|
|
2610
|
+
} else if (program === 'claude') {
|
|
2611
|
+
const command = webRewind.buildClaudeRecordsCommand(engineSessionId);
|
|
2612
|
+
const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
|
|
2613
|
+
const pick = webRewind.pickClaudeResumeAt(records, { text: message.content, timeMs: Date.parse(message.timestamp) });
|
|
2614
|
+
if (pick.found && pick.uuid) {
|
|
2615
|
+
rewindToUuid = pick.uuid;
|
|
2616
|
+
conversationMode = 'precise';
|
|
2617
|
+
} else if (pick.found) {
|
|
2618
|
+
// 回退到第一轮:Claude 那边什么都不需要记得,开新会话
|
|
2619
|
+
engineSessionId = '';
|
|
2620
|
+
conversationMode = 'precise';
|
|
2621
|
+
} else {
|
|
2622
|
+
engineSessionId = '';
|
|
2623
|
+
conversationMode = 'history';
|
|
2624
|
+
}
|
|
2625
|
+
} else {
|
|
2626
|
+
// 其它 Agent 没有精确回退:丢掉引擎会话,下一轮用剩下的聊天记录注入(不丢数据,只丢工具调用等细节)
|
|
2627
|
+
engineSessionId = '';
|
|
2628
|
+
conversationMode = 'history';
|
|
2629
|
+
}
|
|
2630
|
+
const removed = removeWebSessionMessagesFrom(state.webHistoryDir, sessionRef, message.id);
|
|
2631
|
+
if (!removed.length) {
|
|
2632
|
+
return { status: 409, error: '这条提示词已经不在了' };
|
|
2633
|
+
}
|
|
2634
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
|
|
2635
|
+
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
|
|
2636
|
+
}
|
|
2637
|
+
return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2445
2640
|
function resolveWebSessionWorkDir(state, sessionRef) {
|
|
2446
2641
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2447
2642
|
const applied = history.applied && typeof history.applied === 'object' ? history.applied : {};
|
|
@@ -2537,48 +2732,21 @@ function parseCookies(req) {
|
|
|
2537
2732
|
return cookies;
|
|
2538
2733
|
}
|
|
2539
2734
|
|
|
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
2735
|
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;
|
|
2736
|
+
return state.authStore.create(username);
|
|
2557
2737
|
}
|
|
2558
2738
|
|
|
2559
2739
|
function getWebAuthSession(state, req) {
|
|
2560
|
-
|
|
2561
|
-
const cookies = parseCookies(req);
|
|
2562
|
-
const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
|
|
2740
|
+
const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
|
|
2563
2741
|
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 };
|
|
2742
|
+
const session = state.authStore.touch(sessionId);
|
|
2743
|
+
return session ? { sessionId, username: session.username } : null;
|
|
2575
2744
|
}
|
|
2576
2745
|
|
|
2577
2746
|
function clearWebAuthSession(state, req) {
|
|
2578
|
-
const
|
|
2579
|
-
const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
|
|
2747
|
+
const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
|
|
2580
2748
|
if (sessionId) {
|
|
2581
|
-
state.
|
|
2749
|
+
state.authStore.remove(sessionId);
|
|
2582
2750
|
}
|
|
2583
2751
|
}
|
|
2584
2752
|
|
|
@@ -2812,8 +2980,7 @@ function resolveNowTemplate(value, formatDate) {
|
|
|
2812
2980
|
if (typeof value !== 'string') {
|
|
2813
2981
|
return value;
|
|
2814
2982
|
}
|
|
2815
|
-
|
|
2816
|
-
return value.replace(/\{now\}|\$\{now\}/g, nowText);
|
|
2983
|
+
return expandTemplate(value, { now: typeof formatDate === 'function' ? formatDate() : '' }).value;
|
|
2817
2984
|
}
|
|
2818
2985
|
|
|
2819
2986
|
function validateContainerNameStrict(containerName) {
|
|
@@ -3070,7 +3237,7 @@ function buildConfigDefaults(ctx, config) {
|
|
|
3070
3237
|
const parsed = toPlainObject(config);
|
|
3071
3238
|
const defaults = {
|
|
3072
3239
|
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
3073
|
-
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath
|
|
3240
|
+
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath)),
|
|
3074
3241
|
containerPath: pickFirstString(parsed.containerPath),
|
|
3075
3242
|
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
3076
3243
|
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
@@ -3118,7 +3285,8 @@ function buildConfigDefaults(ctx, config) {
|
|
|
3118
3285
|
function buildStaticContainerRuntime(ctx, containerName) {
|
|
3119
3286
|
return {
|
|
3120
3287
|
containerName,
|
|
3121
|
-
|
|
3288
|
+
// 只给已存在的容器用;不带 serve 的启动目录,万一容器在这之间被删也不会按它重建
|
|
3289
|
+
hostPath: '',
|
|
3122
3290
|
containerPath: ctx.containerPath,
|
|
3123
3291
|
imageName: ctx.imageName,
|
|
3124
3292
|
imageVersion: ctx.imageVersion,
|
|
@@ -3132,6 +3300,33 @@ function buildStaticContainerRuntime(ctx, containerName) {
|
|
|
3132
3300
|
};
|
|
3133
3301
|
}
|
|
3134
3302
|
|
|
3303
|
+
function normalizeFirstConfig(value, sourceLabel) {
|
|
3304
|
+
if (value === undefined || value === null) return {};
|
|
3305
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
3306
|
+
throw new Error(`${sourceLabel} 的 first 必须是对象(map),例如 {"shell":"init.sh"}`);
|
|
3307
|
+
}
|
|
3308
|
+
return value;
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// first.envFile 里已存在的文件内容 + first.env,后者优先(与 CLI 一致);返回 KEY=VALUE 行
|
|
3312
|
+
function buildFirstEnvLines(firstResolved) {
|
|
3313
|
+
const lines = [];
|
|
3314
|
+
(firstResolved.envFile || []).forEach(file => {
|
|
3315
|
+
const resolvedPath = expandHomeAliasPath(file);
|
|
3316
|
+
if (!path.isAbsolute(resolvedPath)) {
|
|
3317
|
+
throw new Error(`环境变量文件仅支持绝对路径: ${file}`);
|
|
3318
|
+
}
|
|
3319
|
+
if (!fs.existsSync(resolvedPath)) return;
|
|
3320
|
+
containerState.parseEnvFileContent(fs.readFileSync(resolvedPath, 'utf-8')).entries
|
|
3321
|
+
.forEach(entry => lines.push(`${entry.key}=${entry.value}`));
|
|
3322
|
+
});
|
|
3323
|
+
Object.entries(firstResolved.env || {}).forEach(([key, value]) => {
|
|
3324
|
+
const parsed = parseEnvEntry(`${key}=${value}`);
|
|
3325
|
+
lines.push(`${parsed.key}=${parsed.value}`);
|
|
3326
|
+
});
|
|
3327
|
+
return lines;
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3135
3330
|
function buildCreateRuntime(ctx, state, payload) {
|
|
3136
3331
|
const body = toPlainObject(payload);
|
|
3137
3332
|
const requestOptions = toPlainObject(body.createOptions);
|
|
@@ -3157,6 +3352,13 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3157
3352
|
const requestVolumeList = hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : [];
|
|
3158
3353
|
const requestPortList = hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : [];
|
|
3159
3354
|
|
|
3355
|
+
// first(首次命令):请求里带了就只用它(复制容器时来自快照,已合并过),否则全局 + runs.<name>
|
|
3356
|
+
const hasRequestFirst = hasOwn(requestOptions, 'first');
|
|
3357
|
+
const globalFirstConfig = hasRequestFirst ? {} : normalizeFirstConfig(config.first, '全局配置');
|
|
3358
|
+
const runFirstConfig = hasRequestFirst
|
|
3359
|
+
? normalizeFirstConfig(requestOptions.first, 'createOptions')
|
|
3360
|
+
: normalizeFirstConfig(runConfig.first, '运行配置');
|
|
3361
|
+
|
|
3160
3362
|
const resolvedBase = resolveRuntimeConfig({
|
|
3161
3363
|
cliOptions: {
|
|
3162
3364
|
hostPath: requestOptions.hostPath,
|
|
@@ -3171,10 +3373,11 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3171
3373
|
},
|
|
3172
3374
|
globalConfig: config,
|
|
3173
3375
|
runConfig,
|
|
3174
|
-
globalFirstConfig
|
|
3175
|
-
runFirstConfig
|
|
3376
|
+
globalFirstConfig,
|
|
3377
|
+
runFirstConfig,
|
|
3176
3378
|
defaults: {
|
|
3177
|
-
|
|
3379
|
+
// 不用 ctx.hostPath(serve 的启动目录):与前端新建容器对话框一致,按模板在真正创建时才建目录
|
|
3380
|
+
hostPath: DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
|
|
3178
3381
|
containerName: `my-${ctx.formatDate()}`,
|
|
3179
3382
|
// 不用 ctx.containerPath:那是 serve 进程的启动目录(CLI 的 cwd 语义),与用户要挂载的目录无关;
|
|
3180
3383
|
// 没配置也没传时落到下面的 `|| hostPath`,容器内路径与宿主机路径一致
|
|
@@ -3188,6 +3391,11 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3188
3391
|
isServerStopMode: false,
|
|
3189
3392
|
pickConfigValue: pickFirstString,
|
|
3190
3393
|
resolveContainerNameTemplate: value => resolveNowTemplate(value, ctx.formatDate),
|
|
3394
|
+
// {now} / {quickChatPath} 一次解析只取一个时间:容器名和目录用同一个时间戳
|
|
3395
|
+
templateVars: {
|
|
3396
|
+
now: typeof ctx.formatDate === 'function' ? ctx.formatDate() : undefined,
|
|
3397
|
+
quickChatPath: resolveQuickChatPath(config, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) })
|
|
3398
|
+
},
|
|
3191
3399
|
normalizeCommandSuffix: value => {
|
|
3192
3400
|
const text = String(value || '').trim();
|
|
3193
3401
|
return text ? ` ${text}` : '';
|
|
@@ -3213,6 +3421,10 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3213
3421
|
validateContainerNameStrict(containerName);
|
|
3214
3422
|
|
|
3215
3423
|
const hostPath = resolvedBase.hostPath;
|
|
3424
|
+
// 按模板({now} / {quickChatPath})生成的目录:此刻才建
|
|
3425
|
+
if (resolvedBase.hostPathTemplated && !fs.existsSync(hostPath)) {
|
|
3426
|
+
fs.mkdirSync(hostPath, { recursive: true });
|
|
3427
|
+
}
|
|
3216
3428
|
if (typeof ctx.validateHostPath === 'function') {
|
|
3217
3429
|
ctx.validateHostPath(hostPath);
|
|
3218
3430
|
} else {
|
|
@@ -3312,6 +3524,19 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3312
3524
|
});
|
|
3313
3525
|
}
|
|
3314
3526
|
|
|
3527
|
+
const firstResolved = resolvedBase.first;
|
|
3528
|
+
const firstCommand = String(firstResolved.exec.shell || '').trim()
|
|
3529
|
+
? `${firstResolved.exec.prefix || ''}${firstResolved.exec.shell}${firstResolved.exec.suffix || ''}`
|
|
3530
|
+
: '';
|
|
3531
|
+
// 写进快照的 first 已经是合并后的结果,复制容器时当作请求里的 first 使用
|
|
3532
|
+
const firstSnapshot = {
|
|
3533
|
+
shellPrefix: pickFirstString(runFirstConfig.shellPrefix, globalFirstConfig.shellPrefix),
|
|
3534
|
+
shell: pickFirstString(runFirstConfig.shell, globalFirstConfig.shell),
|
|
3535
|
+
shellSuffix: pickFirstString(runFirstConfig.shellSuffix, globalFirstConfig.shellSuffix),
|
|
3536
|
+
env: firstResolved.env || {},
|
|
3537
|
+
envFile: firstResolved.envFile || []
|
|
3538
|
+
};
|
|
3539
|
+
|
|
3315
3540
|
return {
|
|
3316
3541
|
containerName,
|
|
3317
3542
|
hostPath,
|
|
@@ -3322,6 +3547,7 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3322
3547
|
containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
|
|
3323
3548
|
containerEnvs,
|
|
3324
3549
|
envFiles,
|
|
3550
|
+
first: firstCommand ? { command: firstCommand, envLines: buildFirstEnvLines(firstResolved) } : null,
|
|
3325
3551
|
containerVolumes,
|
|
3326
3552
|
containerPorts,
|
|
3327
3553
|
autostart: manage.autostart,
|
|
@@ -3349,6 +3575,7 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3349
3575
|
ports: resolvedBase.ports || [],
|
|
3350
3576
|
autostart: manage.autostart,
|
|
3351
3577
|
systemPrompt,
|
|
3578
|
+
first: firstSnapshot,
|
|
3352
3579
|
// 复制容器时端口暴露(宿主机端口会冲突)与 peers(按 id 引用别的容器)不带过去
|
|
3353
3580
|
network: { ...manage.network, expose: [], peers: { inbound: [] } }
|
|
3354
3581
|
},
|
|
@@ -3434,6 +3661,7 @@ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
|
|
|
3434
3661
|
ports: runtimeSnapshot.ports || [],
|
|
3435
3662
|
autostart: runtimeSnapshot.autostart || '',
|
|
3436
3663
|
systemPrompt: runtimeSnapshot.systemPrompt || undefined,
|
|
3664
|
+
first: runtimeSnapshot.first || undefined,
|
|
3437
3665
|
network: runtimeSnapshot.network || undefined,
|
|
3438
3666
|
// 快照来自服务端、原容器创建时已确认过,复制不必再确认
|
|
3439
3667
|
confirmRisk: runtimeSnapshot.network ? true : undefined
|
|
@@ -3636,6 +3864,22 @@ async function isWebImagePresent(ctx, imageRef) {
|
|
|
3636
3864
|
}
|
|
3637
3865
|
}
|
|
3638
3866
|
|
|
3867
|
+
// `images --format` 的输出 → [{ name, tag, id, created, size }]:去掉悬空镜像,只留版本号合规(x.y.z-后缀)的,同名同版本去重
|
|
3868
|
+
function parseWebImageList(output) {
|
|
3869
|
+
const seen = new Set();
|
|
3870
|
+
const images = [];
|
|
3871
|
+
for (const line of String(output || '').split('\n')) {
|
|
3872
|
+
const [name, tag, id, created, size] = line.split('\t').map(part => (part || '').trim());
|
|
3873
|
+
if (!name || !tag || name === '<none>' || tag === '<none>') continue;
|
|
3874
|
+
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;
|
|
3875
|
+
const key = `${name}:${tag}`;
|
|
3876
|
+
if (seen.has(key)) continue;
|
|
3877
|
+
seen.add(key);
|
|
3878
|
+
images.push({ name, tag, id: id || '', created: created || '', size: size || '' });
|
|
3879
|
+
}
|
|
3880
|
+
return images;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3639
3883
|
function createImagePreparingError(message) {
|
|
3640
3884
|
const error = new Error(message);
|
|
3641
3885
|
error.statusCode = 409;
|
|
@@ -3729,7 +3973,7 @@ async function cleanupStaleSetupTestContainers(ctx) {
|
|
|
3729
3973
|
}
|
|
3730
3974
|
|
|
3731
3975
|
// Agent 一轮对话里,容器被过滤代理拦下的新域名(浏览器后台请求不算):只在 allowlist 容器上轮询
|
|
3732
|
-
function createWebDeniedNotifier(ctx, sessionRef, emit) {
|
|
3976
|
+
function createWebDeniedNotifier(ctx, sessionRef, emit, modelOptions = {}) {
|
|
3733
3977
|
try {
|
|
3734
3978
|
const homeDir = ctx.homeDir || os.homedir();
|
|
3735
3979
|
const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
|
|
@@ -3737,10 +3981,20 @@ function createWebDeniedNotifier(ctx, sessionRef, emit) {
|
|
|
3737
3981
|
const deniedDir = egressPaths(homeDir).denied;
|
|
3738
3982
|
const policyOf = () => normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined);
|
|
3739
3983
|
if (!usesProxy(policyOf())) return null;
|
|
3984
|
+
// 模型域名:env 里的 URL 域名 + 该 Agent 没配 BASE_URL 时的官方域名;每次现读 env(用户可能刚改过)
|
|
3985
|
+
const isModelHost = host => {
|
|
3986
|
+
const envLines = [
|
|
3987
|
+
...containerState.readEnv(homeDir, id).entries.map(entry => `${entry.key}=${entry.value}`),
|
|
3988
|
+
...containerState.envFileEntries(homeDir, id).map(entry => `${entry.key}=${entry.value}`)
|
|
3989
|
+
];
|
|
3990
|
+
return agentModelDomains(envLines, modelOptions.agentProgram).includes(String(host).toLowerCase());
|
|
3991
|
+
};
|
|
3740
3992
|
return createDeniedNotifier({
|
|
3741
3993
|
read: () => readDenied(deniedDir, id),
|
|
3742
3994
|
isIgnored: host => isBrowserBackground(host) || domainRuleAllows(policyOf(), host),
|
|
3743
|
-
emit
|
|
3995
|
+
emit,
|
|
3996
|
+
isModelHost,
|
|
3997
|
+
onModelDenied: modelOptions.onModelDenied
|
|
3744
3998
|
});
|
|
3745
3999
|
} catch (e) {
|
|
3746
4000
|
return null;
|
|
@@ -3889,9 +4143,26 @@ function getWebNetworkManager(ctx, state) {
|
|
|
3889
4143
|
|
|
3890
4144
|
// 进入容器前一律检查网络规则(宿主机侧的“本次启动已下发”状态):文件浏览也会 exec /bin/bash -lc,
|
|
3891
4145
|
// 里面的 .bashrc 等是 Agent 能改的代码,不能在规则缺失的窗口里跑
|
|
4146
|
+
// 只给名字时:容器还在就不需要运行配置;容器已被删掉,且历史里有创建参数,就按它重建(走 buildCreateRuntime 的路径校验)。
|
|
4147
|
+
// 不再无条件退回 serve 进程的启动目录:那会让重建的容器挂上 $HOME,并且没有原来的 env 和网络设置;
|
|
4148
|
+
// 没有历史也没有容器时直接报错,不再猜参数
|
|
4149
|
+
function resolveRuntimeForExistingName(ctx, state, containerName) {
|
|
4150
|
+
if (ctx.containerExists(containerName)) {
|
|
4151
|
+
return buildStaticContainerRuntime(ctx, containerName);
|
|
4152
|
+
}
|
|
4153
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
4154
|
+
const snapshot = history.runtimeSnapshot && typeof history.runtimeSnapshot === 'object' ? history.runtimeSnapshot : null;
|
|
4155
|
+
if (!snapshot || !snapshot.hostPath) {
|
|
4156
|
+
throw new Error('这个会话没有建容器的参数,请新建容器');
|
|
4157
|
+
}
|
|
4158
|
+
const createOptions = buildCloneCreateOptionsFromSnapshot(snapshot, history);
|
|
4159
|
+
createOptions.containerName = containerName;
|
|
4160
|
+
return buildCreateRuntime(ctx, state, { createOptions });
|
|
4161
|
+
}
|
|
4162
|
+
|
|
3892
4163
|
async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
|
|
3893
4164
|
const runtime = typeof containerInput === 'string'
|
|
3894
|
-
?
|
|
4165
|
+
? resolveRuntimeForExistingName(ctx, state, containerInput)
|
|
3895
4166
|
: containerInput;
|
|
3896
4167
|
|
|
3897
4168
|
if (!runtime || !runtime.containerName) {
|
|
@@ -3967,6 +4238,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3967
4238
|
'system',
|
|
3968
4239
|
`容器 ${runtime.containerName} 已创建并启动。`
|
|
3969
4240
|
);
|
|
4241
|
+
if (runtime.first) {
|
|
4242
|
+
const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
|
|
4243
|
+
appendWebSessionMessage(
|
|
4244
|
+
state.webHistoryDir,
|
|
4245
|
+
messageSessionRef || runtime.containerName,
|
|
4246
|
+
'system',
|
|
4247
|
+
firstResult.exitCode === 0 ? '首次命令已执行' : formatWebFirstFailure(firstResult, runtime.first)
|
|
4248
|
+
);
|
|
4249
|
+
}
|
|
3970
4250
|
return;
|
|
3971
4251
|
}
|
|
3972
4252
|
|
|
@@ -3983,6 +4263,60 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3983
4263
|
await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
|
|
3984
4264
|
}
|
|
3985
4265
|
|
|
4266
|
+
const DEFAULT_NEW_CONTAINER_NAME_TEMPLATE = 'my-easy-{now}';
|
|
4267
|
+
const DEFAULT_NEW_CONTAINER_PATH_TEMPLATE = '{quickChatPath}/{now}';
|
|
4268
|
+
const WEB_FIRST_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
4269
|
+
const WEB_FIRST_COMMAND_OUTPUT_CHARS = 500;
|
|
4270
|
+
|
|
4271
|
+
// 新建容器后执行一次 first(与 CLI 的 executeFirstCommand 同语义,经 buildExecArgs 带上 first.env);
|
|
4272
|
+
// 失败只返回结果,不删容器、不抛错
|
|
4273
|
+
function runWebFirstCommand(ctx, containerName, first) {
|
|
4274
|
+
return new Promise(resolve => {
|
|
4275
|
+
const tail = createTailBuffer(WEB_FIRST_COMMAND_OUTPUT_CHARS * 4);
|
|
4276
|
+
let settled = false;
|
|
4277
|
+
let timer = null;
|
|
4278
|
+
const finish = result => {
|
|
4279
|
+
if (settled) return;
|
|
4280
|
+
settled = true;
|
|
4281
|
+
if (timer) clearTimeout(timer);
|
|
4282
|
+
resolve({ ...result, output: tail.text() });
|
|
4283
|
+
};
|
|
4284
|
+
let child;
|
|
4285
|
+
try {
|
|
4286
|
+
child = spawnContainerExec(
|
|
4287
|
+
ctx,
|
|
4288
|
+
containerName,
|
|
4289
|
+
{ extraEnv: first.envLines, command: ['/bin/bash', '-c', first.command] },
|
|
4290
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
4291
|
+
);
|
|
4292
|
+
} catch (e) {
|
|
4293
|
+
tail.push(e && e.message ? e.message : '');
|
|
4294
|
+
finish({ exitCode: 1 });
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
timer = setTimeout(() => {
|
|
4298
|
+
tail.push('\n[等了 10 分钟仍未结束,已停止等待(容器里的命令可能还在运行)]');
|
|
4299
|
+
child.kill('SIGKILL');
|
|
4300
|
+
finish({ exitCode: 124 });
|
|
4301
|
+
}, ctx.firstCommandTimeoutMs || WEB_FIRST_COMMAND_TIMEOUT_MS);
|
|
4302
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
4303
|
+
stream.setEncoding('utf8');
|
|
4304
|
+
stream.on('data', chunk => tail.push(chunk));
|
|
4305
|
+
}
|
|
4306
|
+
child.on('error', error => {
|
|
4307
|
+
tail.push(error && error.message ? error.message : '');
|
|
4308
|
+
finish({ exitCode: 1 });
|
|
4309
|
+
});
|
|
4310
|
+
child.on('close', (code, signal) => finish({ exitCode: typeof code === 'number' ? code : (signal ? 1 : 0) }));
|
|
4311
|
+
});
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
function formatWebFirstFailure(result, first) {
|
|
4315
|
+
const secrets = first.envLines.map(line => line.slice(line.indexOf('=') + 1));
|
|
4316
|
+
const text = sanitizeServeLogText(redactSecrets(result.output, secrets)).trim().slice(-WEB_FIRST_COMMAND_OUTPUT_CHARS);
|
|
4317
|
+
return `首次命令失败(退出码 ${result.exitCode}):${text}`;
|
|
4318
|
+
}
|
|
4319
|
+
|
|
3986
4320
|
async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
|
|
3987
4321
|
const opts = options && typeof options === 'object' ? options : {};
|
|
3988
4322
|
const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
|
|
@@ -4204,6 +4538,25 @@ try {
|
|
|
4204
4538
|
`);
|
|
4205
4539
|
}
|
|
4206
4540
|
|
|
4541
|
+
// 只判断路径是否存在(点击聊天里的路径时用);不存在不是错误,返回 exists:false
|
|
4542
|
+
function buildContainerPathExistsCommand(requestedPath) {
|
|
4543
|
+
return buildWebContainerNodeCommand(`
|
|
4544
|
+
// __MANYOYO_FS_EXISTS__
|
|
4545
|
+
const fs = require('fs');
|
|
4546
|
+
|
|
4547
|
+
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
4548
|
+
|
|
4549
|
+
let result = { exists: false };
|
|
4550
|
+
try {
|
|
4551
|
+
const stat = fs.statSync(requestedPath);
|
|
4552
|
+
result = { exists: true, type: stat.isDirectory() ? 'dir' : (stat.isFile() ? 'file' : 'other') };
|
|
4553
|
+
} catch (e) {
|
|
4554
|
+
result = { exists: false };
|
|
4555
|
+
}
|
|
4556
|
+
process.stdout.write(JSON.stringify(result));
|
|
4557
|
+
`);
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4207
4560
|
function buildContainerFileReadCommand(requestedPath, options = {}) {
|
|
4208
4561
|
const opts = options && typeof options === 'object' ? options : {};
|
|
4209
4562
|
const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes > 0
|
|
@@ -4930,7 +5283,7 @@ function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
|
|
|
4930
5283
|
}
|
|
4931
5284
|
ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
|
|
4932
5285
|
};
|
|
4933
|
-
runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
|
|
5286
|
+
runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse(), Array.isArray(item.images) ? item.images : [])
|
|
4934
5287
|
.then(turn => {
|
|
4935
5288
|
if (turn && turn.busy) putBack('busy');
|
|
4936
5289
|
else if (turn && turn.error) putBack(turn.error);
|
|
@@ -4960,6 +5313,8 @@ function listLiveRunsInContainer(ctx, containerName) {
|
|
|
4960
5313
|
// 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
|
|
4961
5314
|
// 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
|
|
4962
5315
|
async function resumeQueuedAgentTurns(ctx, state) {
|
|
5316
|
+
// 先等启动对账:否则会在还挂着旧 pending 的会话上再开新一轮
|
|
5317
|
+
try { await state.orphanReconcileReady; } catch (e) { /* 对账失败不影响排队恢复 */ }
|
|
4963
5318
|
let containers = {};
|
|
4964
5319
|
try {
|
|
4965
5320
|
containers = listWebManyoyoContainers(ctx);
|
|
@@ -5073,10 +5428,135 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
|
|
|
5073
5428
|
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
5074
5429
|
}
|
|
5075
5430
|
|
|
5431
|
+
// ---- serve 启动对账 ----
|
|
5432
|
+
// serve 重启会丢掉内存里的运行登记,历史里留着 pending=true 的消息,前端据此永远显示“进行中”。
|
|
5433
|
+
// 启动时记下当时就 pending 的那批消息 id(之后新发起的轮次不碰),再按容器里是否还有活着的运行分两种处理:
|
|
5434
|
+
// 没有 → 立即标成已中断;还有(docker 下容器里的 agent 会比 serve 活得久)→ 每 5 秒查一次,跑完再标。
|
|
5435
|
+
const WEB_ORPHAN_POLL_MS = 5000;
|
|
5436
|
+
const ORPHAN_NOTE_INTERRUPTED = {
|
|
5437
|
+
trace: '服务重启,本轮已中断',
|
|
5438
|
+
reply: '服务重启时这一轮被中断了。可以发送“继续”让它接着做。'
|
|
5439
|
+
};
|
|
5440
|
+
const ORPHAN_NOTE_FINISHED = {
|
|
5441
|
+
trace: '服务重启,本轮在后台跑完,结果未接收',
|
|
5442
|
+
reply: '服务重启后这一轮在后台跑完了,但结果没能接收;可以发送“继续”让它总结。'
|
|
5443
|
+
};
|
|
5444
|
+
|
|
5445
|
+
function collectStartupPending(ctx, state) {
|
|
5446
|
+
const entries = [];
|
|
5447
|
+
let names = [];
|
|
5448
|
+
try {
|
|
5449
|
+
names = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
5450
|
+
} catch (e) {
|
|
5451
|
+
ctx.logger.warn('orphan reconcile: list history failed', { message: e && e.message });
|
|
5452
|
+
return entries;
|
|
5453
|
+
}
|
|
5454
|
+
for (const containerName of names) {
|
|
5455
|
+
try {
|
|
5456
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
5457
|
+
for (const [agentId, agentSession] of Object.entries(history.agents || {})) {
|
|
5458
|
+
const ids = (agentSession.messages || []).filter(message => message && message.pending === true).map(message => message.id);
|
|
5459
|
+
if (ids.length) entries.push({ containerName, agentId, ids: new Set(ids) });
|
|
5460
|
+
}
|
|
5461
|
+
} catch (e) {
|
|
5462
|
+
ctx.logger.warn('orphan reconcile: skip unreadable history', { container: containerName, message: e && e.message });
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
return entries;
|
|
5466
|
+
}
|
|
5467
|
+
|
|
5468
|
+
// 只改启动时记下的那些消息;这一轮的执行过程末尾补一行,最终回复(没有流式半截回复就新建一条)写一句说明
|
|
5469
|
+
function finalizeOrphanEntry(ctx, state, entry, note) {
|
|
5470
|
+
const sessionRef = { containerName: entry.containerName, agentId: entry.agentId };
|
|
5471
|
+
const history = loadWebSessionHistory(state.webHistoryDir, entry.containerName);
|
|
5472
|
+
const agentSession = getWebAgentSession(history, entry.agentId, { includeArchived: true });
|
|
5473
|
+
if (!agentSession || !Array.isArray(agentSession.messages)) return;
|
|
5474
|
+
const running = state.agentRuns.get(entry.containerName);
|
|
5475
|
+
const newerRunInSession = Boolean(running && running.sessionRef && running.sessionRef.agentId === entry.agentId);
|
|
5476
|
+
let touched = false;
|
|
5477
|
+
let replied = false;
|
|
5478
|
+
for (const message of agentSession.messages) {
|
|
5479
|
+
if (!message || message.pending !== true || !entry.ids.has(message.id)) continue;
|
|
5480
|
+
message.pending = false;
|
|
5481
|
+
message.interrupted = true;
|
|
5482
|
+
touched = true;
|
|
5483
|
+
if (message.streamTrace) {
|
|
5484
|
+
message.content = `${message.content || ''}\n[任务] ${note.trace}`;
|
|
5485
|
+
} else if (message.streamingReply) {
|
|
5486
|
+
message.streamingReply = false;
|
|
5487
|
+
message.content = message.content ? `${message.content}\n\n${note.reply}` : note.reply;
|
|
5488
|
+
replied = true;
|
|
5489
|
+
}
|
|
5490
|
+
}
|
|
5491
|
+
if (!touched) return;
|
|
5492
|
+
const timestamp = new Date().toISOString();
|
|
5493
|
+
agentSession.updatedAt = timestamp;
|
|
5494
|
+
history.updatedAt = timestamp;
|
|
5495
|
+
saveWebSessionHistory(state.webHistoryDir, entry.containerName, history);
|
|
5496
|
+
// 这个会话已经有新的一轮在跑:不在末尾插入说明,免得夹进新一轮的消息中间
|
|
5497
|
+
if (!replied && !newerRunInSession) {
|
|
5498
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', note.reply, { mode: 'agent', interrupted: true });
|
|
5499
|
+
}
|
|
5500
|
+
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'process.interrupted', { reason: 'serve-restart' });
|
|
5501
|
+
}
|
|
5502
|
+
|
|
5503
|
+
function isWebContainerRunning(ctx, containerName) {
|
|
5504
|
+
try {
|
|
5505
|
+
return Boolean(ctx.containerExists(containerName)) && ctx.getContainerStatus(containerName) === 'running';
|
|
5506
|
+
} catch (e) {
|
|
5507
|
+
return false;
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5511
|
+
async function waitForRuntimeReady(ctx) {
|
|
5512
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
5513
|
+
while (ctx.runtimeState && ctx.runtimeState.status === 'starting' && Date.now() < deadline) {
|
|
5514
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
5515
|
+
}
|
|
5516
|
+
}
|
|
5517
|
+
|
|
5518
|
+
async function reconcileOrphanTurns(ctx, state) {
|
|
5519
|
+
const entries = collectStartupPending(ctx, state);
|
|
5520
|
+
if (!entries.length) return;
|
|
5521
|
+
await waitForRuntimeReady(ctx);
|
|
5522
|
+
const byContainer = new Map();
|
|
5523
|
+
for (const entry of entries) {
|
|
5524
|
+
if (!byContainer.has(entry.containerName)) byContainer.set(entry.containerName, []);
|
|
5525
|
+
byContainer.get(entry.containerName).push(entry);
|
|
5526
|
+
}
|
|
5527
|
+
for (const [containerName, list] of byContainer) {
|
|
5528
|
+
const live = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
|
|
5529
|
+
if (!live.length) {
|
|
5530
|
+
for (const entry of list) {
|
|
5531
|
+
try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_INTERRUPTED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
|
|
5532
|
+
}
|
|
5533
|
+
continue;
|
|
5534
|
+
}
|
|
5535
|
+
let checking = false;
|
|
5536
|
+
const timer = setInterval(async () => {
|
|
5537
|
+
if (checking) return;
|
|
5538
|
+
checking = true;
|
|
5539
|
+
try {
|
|
5540
|
+
const stillLive = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
|
|
5541
|
+
if (stillLive.length) return;
|
|
5542
|
+
clearInterval(timer);
|
|
5543
|
+
state.orphanTimers.delete(timer);
|
|
5544
|
+
for (const entry of list) {
|
|
5545
|
+
try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_FINISHED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
|
|
5546
|
+
}
|
|
5547
|
+
} finally {
|
|
5548
|
+
checking = false;
|
|
5549
|
+
}
|
|
5550
|
+
}, ctx.orphanPollMs || WEB_ORPHAN_POLL_MS);
|
|
5551
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
5552
|
+
state.orphanTimers.add(timer);
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
|
|
5076
5556
|
// 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
|
|
5077
5557
|
// 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
|
|
5078
5558
|
// 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
|
|
5079
|
-
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
|
|
5559
|
+
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
|
|
5080
5560
|
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
5081
5561
|
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
5082
5562
|
return { busy: true };
|
|
@@ -5090,6 +5570,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5090
5570
|
userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
5091
5571
|
mode: 'agent',
|
|
5092
5572
|
pending: true,
|
|
5573
|
+
...(images.length ? { images } : {}),
|
|
5093
5574
|
...(clientMessageId ? { clientMessageId: clientMessageId } : {})
|
|
5094
5575
|
});
|
|
5095
5576
|
traceMessage = appendWebAgentTraceMessage(
|
|
@@ -5109,7 +5590,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5109
5590
|
|
|
5110
5591
|
let prepared = null;
|
|
5111
5592
|
try {
|
|
5112
|
-
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
5593
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
|
|
5113
5594
|
} catch (e) {
|
|
5114
5595
|
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
5115
5596
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
@@ -5271,9 +5752,23 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5271
5752
|
|
|
5272
5753
|
};
|
|
5273
5754
|
// 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
|
|
5274
|
-
|
|
5755
|
+
// 被拦的是 Agent 自己的模型域名:继续等只会白等到超时,立即中止本轮(不暂停排队,不是用户点的停止)
|
|
5756
|
+
const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }), {
|
|
5757
|
+
agentProgram: agentMeta.agentProgram,
|
|
5758
|
+
onModelDenied: host => {
|
|
5759
|
+
if (runState.modelDenied) return;
|
|
5760
|
+
runState.modelDenied = host;
|
|
5761
|
+
handleStreamEvent({ type: 'network_denied', host, model: true });
|
|
5762
|
+
runState.stopping = true;
|
|
5763
|
+
if (runState.process) terminateWebAgentRun(ctx, runState);
|
|
5764
|
+
}
|
|
5765
|
+
});
|
|
5766
|
+
let snapBefore = { skipped: 'unavailable' };
|
|
5275
5767
|
try {
|
|
5276
5768
|
if (deniedNotifier) deniedNotifier.start();
|
|
5769
|
+
snapBefore = await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} before`);
|
|
5770
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id,
|
|
5771
|
+
snapBefore.sha ? { snapBefore: snapBefore.sha } : { snapSkipped: snapBefore.skipped });
|
|
5277
5772
|
let result;
|
|
5278
5773
|
try {
|
|
5279
5774
|
result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
@@ -5291,14 +5786,21 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5291
5786
|
exitCode: result.exitCode,
|
|
5292
5787
|
interrupted: result.interrupted === true
|
|
5293
5788
|
});
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5789
|
+
const modelDeniedHost = runState.modelDenied || '';
|
|
5790
|
+
traceLines.push(modelDeniedHost
|
|
5791
|
+
? '[任务] 已中止:模型地址被网络策略拦截'
|
|
5792
|
+
: (result.interrupted === true
|
|
5793
|
+
? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
|
|
5794
|
+
: '[任务] 已完成'));
|
|
5795
|
+
const snapAfter = snapBefore.sha
|
|
5796
|
+
? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
|
|
5797
|
+
: { skipped: snapBefore.skipped };
|
|
5297
5798
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5298
5799
|
pending: false,
|
|
5299
5800
|
contextMode,
|
|
5300
5801
|
resumeAttempted,
|
|
5301
|
-
resumeSucceeded
|
|
5802
|
+
resumeSucceeded,
|
|
5803
|
+
...(snapAfter.sha ? { snapAfter: snapAfter.sha } : {})
|
|
5302
5804
|
});
|
|
5303
5805
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5304
5806
|
content: traceLines.join('\n'),
|
|
@@ -5312,26 +5814,30 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5312
5814
|
if (streamingReplyMessageId) {
|
|
5313
5815
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
|
|
5314
5816
|
}
|
|
5817
|
+
const finalResult = modelDeniedHost
|
|
5818
|
+
? { ...result, interrupted: true, output: `已中止:模型地址 ${modelDeniedHost} 被网络策略拦截。`, networkDenied: { host: modelDeniedHost, model: true } }
|
|
5819
|
+
: result;
|
|
5315
5820
|
const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
|
|
5316
5821
|
contextMode,
|
|
5317
5822
|
resumeAttempted,
|
|
5318
5823
|
resumeSucceeded,
|
|
5319
5824
|
resumeError,
|
|
5320
5825
|
engineSessionId
|
|
5321
|
-
},
|
|
5826
|
+
}, finalResult);
|
|
5322
5827
|
void refreshWebAgentTitle(ctx, state, sessionRef, agentMeta.agentProgram);
|
|
5323
5828
|
emitStreamEvent({
|
|
5324
5829
|
type: 'result',
|
|
5325
|
-
exitCode:
|
|
5326
|
-
output:
|
|
5830
|
+
exitCode: finalResult.exitCode,
|
|
5831
|
+
output: finalResult.output,
|
|
5832
|
+
...(finalResult.networkDenied ? { networkDenied: finalResult.networkDenied } : {}),
|
|
5327
5833
|
// 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
|
|
5328
5834
|
// 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
|
|
5329
5835
|
replyMessageId: replyMessage && replyMessage.id,
|
|
5330
5836
|
contextMode,
|
|
5331
5837
|
resumeAttempted,
|
|
5332
5838
|
resumeSucceeded,
|
|
5333
|
-
interrupted:
|
|
5334
|
-
},
|
|
5839
|
+
interrupted: finalResult.interrupted === true
|
|
5840
|
+
}, finalResult.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
|
|
5335
5841
|
} catch (e) {
|
|
5336
5842
|
ctx.logger.error('agent turn failed', {
|
|
5337
5843
|
session: turnSessionKey,
|
|
@@ -5339,11 +5845,15 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5339
5845
|
message: e && e.message ? e.message : 'Agent 执行失败'
|
|
5340
5846
|
});
|
|
5341
5847
|
traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
|
|
5848
|
+
const failedSnapAfter = snapBefore.sha
|
|
5849
|
+
? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
|
|
5850
|
+
: { skipped: snapBefore.skipped };
|
|
5342
5851
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5343
5852
|
pending: false,
|
|
5344
5853
|
contextMode,
|
|
5345
5854
|
resumeAttempted,
|
|
5346
|
-
resumeSucceeded
|
|
5855
|
+
resumeSucceeded,
|
|
5856
|
+
...(failedSnapAfter.sha ? { snapAfter: failedSnapAfter.sha } : {})
|
|
5347
5857
|
});
|
|
5348
5858
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5349
5859
|
content: traceLines.join('\n'),
|
|
@@ -5365,7 +5875,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5365
5875
|
clearInterval(heartbeatTimer);
|
|
5366
5876
|
clearTimeout(runState.stepTimer);
|
|
5367
5877
|
// 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
|
|
5368
|
-
const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
|
|
5878
|
+
const hasNext = runState.userStopped !== true && !runState.modelDenied && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
|
|
5369
5879
|
if (hasNext && !res.writableEnded && !res.destroyed) {
|
|
5370
5880
|
// 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
|
|
5371
5881
|
sendNdjson(res, { type: 'queue_next' });
|
|
@@ -5389,6 +5899,38 @@ function getSetupWorkpath(state) {
|
|
|
5389
5899
|
return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
|
|
5390
5900
|
}
|
|
5391
5901
|
|
|
5902
|
+
// 工作目录根:默认的 ~/.manyoyo/work;配置里的快捷对话目录(serve.quickChat.path)另算,storage 里会先校验再用
|
|
5903
|
+
function getStorageRootsConfig(ctx, state) {
|
|
5904
|
+
const extraWorkRoots = [];
|
|
5905
|
+
const configHostPaths = [];
|
|
5906
|
+
try {
|
|
5907
|
+
const parsed = readWebConfigSnapshot(state.webConfigPath).parsed;
|
|
5908
|
+
// 配置里写死的挂载目录(全局与每个 runs.<名>):删除时算在用;含 {模板} 的每次不同,不算
|
|
5909
|
+
const fixedHostPaths = [parsed && parsed.hostPath, ...Object.values((parsed && parsed.runs) || {}).map(run => run && run.hostPath)];
|
|
5910
|
+
for (const value of fixedHostPaths) {
|
|
5911
|
+
if (typeof value === 'string' && value.trim() && !value.includes('{')) configHostPaths.push(expandHomeAliasPath(value.trim()));
|
|
5912
|
+
}
|
|
5913
|
+
const quickChatPath = parsed && parsed.serve && parsed.serve.quickChat && parsed.serve.quickChat.path;
|
|
5914
|
+
if (typeof quickChatPath === 'string' && quickChatPath.trim()) extraWorkRoots.push(expandHomeAliasPath(quickChatPath.trim()));
|
|
5915
|
+
} catch (e) { /* 配置读不出来就只认默认目录 */ }
|
|
5916
|
+
return {
|
|
5917
|
+
workRoots: [getSetupWorkpath(state)],
|
|
5918
|
+
extraWorkRoots,
|
|
5919
|
+
configHostPaths,
|
|
5920
|
+
homeDir: ctx.homeDir || os.homedir(),
|
|
5921
|
+
dockerExecAsync: ctx.dockerExecAsync || (async (args) => ctx.dockerExecArgs(args))
|
|
5922
|
+
};
|
|
5923
|
+
}
|
|
5924
|
+
|
|
5925
|
+
async function loadStorageListing(ctx, state) {
|
|
5926
|
+
const listing = await webStorage.buildStorageListing({
|
|
5927
|
+
...getStorageRootsConfig(ctx, state),
|
|
5928
|
+
historyDir: state.webHistoryDir
|
|
5929
|
+
});
|
|
5930
|
+
state.storageKnownPaths = new Set(listing.rows.map(row => row.path));
|
|
5931
|
+
return listing;
|
|
5932
|
+
}
|
|
5933
|
+
|
|
5392
5934
|
async function inspectSetupImage(ctx, state) {
|
|
5393
5935
|
const image = `${ctx.imageName}:${ctx.imageVersion}`;
|
|
5394
5936
|
const pulling = state && state.imagePulls.get(image);
|
|
@@ -5416,6 +5958,15 @@ async function inspectSetupImage(ctx, state) {
|
|
|
5416
5958
|
}
|
|
5417
5959
|
}
|
|
5418
5960
|
|
|
5961
|
+
// 镜像里装了哪些 Agent CLI;镜像没就绪或探测失败返回 null(未知,向导照常可选)
|
|
5962
|
+
async function probeSetupImageAgents(ctx, state) {
|
|
5963
|
+
if (!ctx.dockerExecAsync) return null;
|
|
5964
|
+
const image = await inspectSetupImage(ctx, state);
|
|
5965
|
+
if (image.status !== 'ready') return null;
|
|
5966
|
+
if (!state.imageAgentProbe) state.imageAgentProbe = createImageAgentProbe(ctx.dockerExecAsync);
|
|
5967
|
+
return state.imageAgentProbe(image.name);
|
|
5968
|
+
}
|
|
5969
|
+
|
|
5419
5970
|
// 在一次性容器里用所选 Agent 发一次最小请求;容器无论成败都会删除。
|
|
5420
5971
|
async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
5421
5972
|
const secrets = Object.values(envMap);
|
|
@@ -5429,7 +5980,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
5429
5980
|
if (!template) {
|
|
5430
5981
|
return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
|
|
5431
5982
|
}
|
|
5432
|
-
const agentCommand = renderAgentPromptCommand(template, SETUP_TEST_PROMPT);
|
|
5983
|
+
const agentCommand = withCodexBaseUrl(renderAgentPromptCommand(template, SETUP_TEST_PROMPT));
|
|
5433
5984
|
const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
|
|
5434
5985
|
|
|
5435
5986
|
const workpath = getSetupWorkpath(state);
|
|
@@ -5687,7 +6238,7 @@ function buildSessionFallbackApplied(ctx, state, name, history, summary) {
|
|
|
5687
6238
|
|
|
5688
6239
|
return {
|
|
5689
6240
|
containerName: name,
|
|
5690
|
-
hostPath: defaults.hostPath ||
|
|
6241
|
+
hostPath: defaults.hostPath || '',
|
|
5691
6242
|
containerPath: defaults.containerPath || ctx.containerPath || '',
|
|
5692
6243
|
imageName: defaults.imageName || ctx.imageName || '',
|
|
5693
6244
|
imageVersion: defaults.imageVersion || ctx.imageVersion || '',
|
|
@@ -6419,6 +6970,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6419
6970
|
});
|
|
6420
6971
|
}
|
|
6421
6972
|
},
|
|
6973
|
+
{
|
|
6974
|
+
// 新建容器对话框的默认值:配置里写了就用配置的,没写就用默认模板
|
|
6975
|
+
// (容器名 my-easy-{now},宿主机目录与容器内目录 {quickChatPath}/{now}),由前端展开;目录在点“创建”时才建
|
|
6976
|
+
method: 'GET',
|
|
6977
|
+
match: currentPath => currentPath === '/api/system/new-container-defaults' ? [] : null,
|
|
6978
|
+
handler: async () => {
|
|
6979
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
6980
|
+
const config = snapshot.parseError ? {} : toPlainObject(snapshot.parsed);
|
|
6981
|
+
const hostPathConfigured = Boolean(pickFirstString(config.hostPath));
|
|
6982
|
+
sendJson(res, 200, {
|
|
6983
|
+
containerName: pickFirstString(config.containerName) || DEFAULT_NEW_CONTAINER_NAME_TEMPLATE,
|
|
6984
|
+
hostPath: pickFirstString(config.hostPath) || DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
|
|
6985
|
+
containerPath: pickFirstString(config.containerPath) || (hostPathConfigured ? '' : DEFAULT_NEW_CONTAINER_PATH_TEMPLATE),
|
|
6986
|
+
quickChatPath: resolveQuickChatPath(config, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) })
|
|
6987
|
+
});
|
|
6988
|
+
}
|
|
6989
|
+
},
|
|
6422
6990
|
{
|
|
6423
6991
|
// 系统设置“Agent 环境说明”:内置说明(只读)+ 全局配置里用户追加的说明
|
|
6424
6992
|
method: 'GET',
|
|
@@ -6541,11 +7109,35 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6541
7109
|
sendJson(res, 200, await buildSetupStatus(ctx, state));
|
|
6542
7110
|
}
|
|
6543
7111
|
},
|
|
7112
|
+
{
|
|
7113
|
+
// 本机已有的镜像(系统设置「基础」里选镜像名 / 版本用):只列版本号符合 x.y.z-后缀 的,按镜像名分组
|
|
7114
|
+
method: 'GET',
|
|
7115
|
+
match: currentPath => currentPath === '/api/images' ? [] : null,
|
|
7116
|
+
handler: async () => {
|
|
7117
|
+
const defaults = { imageName: ctx.imageName || '', imageVersion: ctx.imageVersion || '' };
|
|
7118
|
+
if (!ctx.dockerExecAsync || !ctx.runtimeState || ctx.runtimeState.status !== 'ready') {
|
|
7119
|
+
sendJson(res, 200, { defaults, images: [] });
|
|
7120
|
+
return;
|
|
7121
|
+
}
|
|
7122
|
+
let output = '';
|
|
7123
|
+
try {
|
|
7124
|
+
output = String(await ctx.dockerExecAsync(['images', '-a', '--format', '{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}'], { timeout: 15000 }) || '');
|
|
7125
|
+
} catch (e) {
|
|
7126
|
+
output = '';
|
|
7127
|
+
}
|
|
7128
|
+
sendJson(res, 200, { defaults, images: parseWebImageList(output) });
|
|
7129
|
+
}
|
|
7130
|
+
},
|
|
6544
7131
|
{
|
|
6545
7132
|
method: 'GET',
|
|
6546
7133
|
match: currentPath => currentPath === '/api/setup/agents' ? [] : null,
|
|
6547
7134
|
handler: async () => {
|
|
6548
|
-
|
|
7135
|
+
const installed = await probeSetupImageAgents(ctx, state);
|
|
7136
|
+
const agents = listSetupAgents().map(agent => ({
|
|
7137
|
+
...agent,
|
|
7138
|
+
available: installed ? installed.includes(agent.id) : null
|
|
7139
|
+
}));
|
|
7140
|
+
sendJson(res, 200, { agents, mirrorPresets: listMirrorPresets() });
|
|
6549
7141
|
}
|
|
6550
7142
|
},
|
|
6551
7143
|
{
|
|
@@ -7100,6 +7692,149 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7100
7692
|
sendJson(res, 200, payload);
|
|
7101
7693
|
}
|
|
7102
7694
|
},
|
|
7695
|
+
{
|
|
7696
|
+
// 磁盘占用:宿主机工作目录与已删除容器的历史(体积另走 /size,大目录不拖慢列表)
|
|
7697
|
+
method: 'GET',
|
|
7698
|
+
match: currentPath => currentPath === '/api/storage' ? [] : null,
|
|
7699
|
+
handler: async () => {
|
|
7700
|
+
let listing;
|
|
7701
|
+
try {
|
|
7702
|
+
listing = await loadStorageListing(ctx, state);
|
|
7703
|
+
} catch (e) {
|
|
7704
|
+
sendJson(res, 503, { error: '容器运行时不可用,暂时无法判断目录是否还在使用' });
|
|
7705
|
+
return;
|
|
7706
|
+
}
|
|
7707
|
+
sendJson(res, 200, listing);
|
|
7708
|
+
}
|
|
7709
|
+
},
|
|
7710
|
+
{
|
|
7711
|
+
method: 'GET',
|
|
7712
|
+
match: currentPath => currentPath === '/api/storage/size' ? [] : null,
|
|
7713
|
+
handler: async () => {
|
|
7714
|
+
const target = String(new URL(req.url || '/', 'http://localhost').searchParams.get('path') || '');
|
|
7715
|
+
if (!state.storageKnownPaths || !state.storageKnownPaths.has(target)) {
|
|
7716
|
+
sendJson(res, 404, { error: '没有这个目录,请刷新列表' });
|
|
7717
|
+
return;
|
|
7718
|
+
}
|
|
7719
|
+
sendJson(res, 200, await webStorage.directorySize(target));
|
|
7720
|
+
}
|
|
7721
|
+
},
|
|
7722
|
+
{
|
|
7723
|
+
method: 'GET',
|
|
7724
|
+
match: currentPath => currentPath === '/api/storage/files' ? [] : null,
|
|
7725
|
+
handler: async () => {
|
|
7726
|
+
const params = new URL(req.url || '/', 'http://localhost').searchParams;
|
|
7727
|
+
const target = String(params.get('path') || '');
|
|
7728
|
+
if (!state.storageKnownPaths || !state.storageKnownPaths.has(target)) {
|
|
7729
|
+
sendJson(res, 404, { error: '没有这个目录,请刷新列表' });
|
|
7730
|
+
return;
|
|
7731
|
+
}
|
|
7732
|
+
const listed = await webStorage.listDirectory(target, params.get('sub') || '');
|
|
7733
|
+
sendJson(res, listed.ok ? 200 : listed.status, listed.ok ? listed : { error: listed.error });
|
|
7734
|
+
}
|
|
7735
|
+
},
|
|
7736
|
+
{
|
|
7737
|
+
method: 'DELETE',
|
|
7738
|
+
match: currentPath => currentPath === '/api/storage/dirs' ? [] : null,
|
|
7739
|
+
handler: async () => {
|
|
7740
|
+
const body = (await readJsonBody(req)) || {};
|
|
7741
|
+
let listing;
|
|
7742
|
+
try {
|
|
7743
|
+
listing = await loadStorageListing(ctx, state);
|
|
7744
|
+
} catch (e) {
|
|
7745
|
+
sendJson(res, 503, { error: '容器运行时不可用,不能确认目录没人用,已拒绝删除' });
|
|
7746
|
+
return;
|
|
7747
|
+
}
|
|
7748
|
+
const removed = await webStorage.removeManagedDirectory(listing, String(body.path || ''), { emptyOnly: body.emptyOnly === true });
|
|
7749
|
+
sendJson(res, removed.ok ? 200 : removed.status, removed.ok ? { removed: true } : { error: removed.error });
|
|
7750
|
+
}
|
|
7751
|
+
},
|
|
7752
|
+
{
|
|
7753
|
+
method: 'DELETE',
|
|
7754
|
+
match: currentPath => currentPath === '/api/storage/histories' ? [] : null,
|
|
7755
|
+
handler: async () => {
|
|
7756
|
+
const body = (await readJsonBody(req)) || {};
|
|
7757
|
+
const name = String(body.name || '');
|
|
7758
|
+
if (!ctx.isValidContainerName(name)) {
|
|
7759
|
+
sendJson(res, 400, { error: '名称不合法' });
|
|
7760
|
+
return;
|
|
7761
|
+
}
|
|
7762
|
+
let listing;
|
|
7763
|
+
try {
|
|
7764
|
+
listing = await loadStorageListing(ctx, state);
|
|
7765
|
+
} catch (e) {
|
|
7766
|
+
sendJson(res, 503, { error: '容器运行时不可用,不能确认容器已删除,已拒绝清理' });
|
|
7767
|
+
return;
|
|
7768
|
+
}
|
|
7769
|
+
const removed = await webStorage.removeDeletedHistory(
|
|
7770
|
+
state.webHistoryDir, listing, name, historyName => removeWebSessionHistory(state.webHistoryDir, historyName)
|
|
7771
|
+
);
|
|
7772
|
+
sendJson(res, removed.ok ? 200 : removed.status, removed.ok ? { removed: true } : { error: removed.error });
|
|
7773
|
+
}
|
|
7774
|
+
},
|
|
7775
|
+
{
|
|
7776
|
+
// 聊天里的图片:原始请求体,类型只收 png/jpeg/gif/webp,单张 10MB
|
|
7777
|
+
method: 'POST',
|
|
7778
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/uploads$/),
|
|
7779
|
+
handler: async match => {
|
|
7780
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7781
|
+
if (!sessionRef) {
|
|
7782
|
+
return;
|
|
7783
|
+
}
|
|
7784
|
+
const ext = webUploads.extForContentType(req.headers['content-type']);
|
|
7785
|
+
if (!ext) {
|
|
7786
|
+
sendJson(res, 415, { error: '只支持 png、jpeg、gif、webp 图片' });
|
|
7787
|
+
return;
|
|
7788
|
+
}
|
|
7789
|
+
const declared = Number(req.headers['content-length']);
|
|
7790
|
+
if (Number.isFinite(declared) && declared > webUploads.MAX_IMAGE_BYTES) {
|
|
7791
|
+
sendJson(res, 413, { error: '图片不能超过 10MB' });
|
|
7792
|
+
return;
|
|
7793
|
+
}
|
|
7794
|
+
if (!ctx.containerExists(sessionRef.containerName)) {
|
|
7795
|
+
sendJson(res, 404, { error: `容器不存在: ${sessionRef.containerName}` });
|
|
7796
|
+
return;
|
|
7797
|
+
}
|
|
7798
|
+
const homeDir = ctx.homeDir || os.homedir();
|
|
7799
|
+
const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
|
|
7800
|
+
if (!id || !containerState.stateExists(homeDir, id)) {
|
|
7801
|
+
sendJson(res, 409, { error: '这个容器太旧,不支持发图片,重建后可用' });
|
|
7802
|
+
return;
|
|
7803
|
+
}
|
|
7804
|
+
const saved = await webUploads.saveUpload(req, path.join(containerState.paths(homeDir, id).box, 'uploads'), ext);
|
|
7805
|
+
if (!saved.ok) {
|
|
7806
|
+
sendJson(res, saved.status, { error: saved.error });
|
|
7807
|
+
return;
|
|
7808
|
+
}
|
|
7809
|
+
sendJson(res, 200, { path: `${webUploads.CONTAINER_UPLOAD_DIR}/${saved.name}` });
|
|
7810
|
+
}
|
|
7811
|
+
},
|
|
7812
|
+
{
|
|
7813
|
+
method: 'GET',
|
|
7814
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/stat$/),
|
|
7815
|
+
handler: async match => {
|
|
7816
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7817
|
+
if (!sessionRef) {
|
|
7818
|
+
return;
|
|
7819
|
+
}
|
|
7820
|
+
const requestUrl = new URL(req.url || '/api/sessions/x/fs/stat', 'http://localhost');
|
|
7821
|
+
const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
|
|
7822
|
+
// 点一下文字不该拉起容器:没在运行就当作不存在
|
|
7823
|
+
if (!targetPath || !ctx.containerExists(sessionRef.containerName) || ctx.getContainerStatus(sessionRef.containerName) !== 'running') {
|
|
7824
|
+
sendJson(res, 200, { exists: false });
|
|
7825
|
+
return;
|
|
7826
|
+
}
|
|
7827
|
+
let payload = null;
|
|
7828
|
+
try {
|
|
7829
|
+
payload = await execJsonCommandInWebContainer(ctx, sessionRef.containerName, buildContainerPathExistsCommand(targetPath));
|
|
7830
|
+
} catch (e) {
|
|
7831
|
+
payload = null;
|
|
7832
|
+
}
|
|
7833
|
+
sendJson(res, 200, payload && payload.exists === true
|
|
7834
|
+
? { exists: true, type: payload.type }
|
|
7835
|
+
: { exists: false });
|
|
7836
|
+
}
|
|
7837
|
+
},
|
|
7103
7838
|
{
|
|
7104
7839
|
method: 'GET',
|
|
7105
7840
|
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/read$/),
|
|
@@ -7320,6 +8055,130 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7320
8055
|
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
|
|
7321
8056
|
}
|
|
7322
8057
|
},
|
|
8058
|
+
{
|
|
8059
|
+
// 按轮看改动:比较某一轮前后两个快照(提交号必须是 40 位小写十六进制)
|
|
8060
|
+
method: 'GET',
|
|
8061
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/changes$/),
|
|
8062
|
+
handler: async match => {
|
|
8063
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8064
|
+
if (!sessionRef) {
|
|
8065
|
+
return;
|
|
8066
|
+
}
|
|
8067
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
8068
|
+
const from = String(requestUrl.searchParams.get('from') || '');
|
|
8069
|
+
const to = String(requestUrl.searchParams.get('to') || '');
|
|
8070
|
+
if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
|
|
8071
|
+
sendJson(res, 400, { error: '快照号不合法' });
|
|
8072
|
+
return;
|
|
8073
|
+
}
|
|
8074
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
8075
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(resolveWebSessionWorkDir(state, sessionRef), from, to), 30000);
|
|
8076
|
+
sendJson(res, 200, webSnapshots.parseRoundChangesOutput(out));
|
|
8077
|
+
}
|
|
8078
|
+
},
|
|
8079
|
+
{
|
|
8080
|
+
method: 'GET',
|
|
8081
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/diff$/),
|
|
8082
|
+
handler: async match => {
|
|
8083
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8084
|
+
if (!sessionRef) {
|
|
8085
|
+
return;
|
|
8086
|
+
}
|
|
8087
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
8088
|
+
const from = String(requestUrl.searchParams.get('from') || '');
|
|
8089
|
+
const to = String(requestUrl.searchParams.get('to') || '');
|
|
8090
|
+
const filePath = String(requestUrl.searchParams.get('path') || '');
|
|
8091
|
+
if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
|
|
8092
|
+
sendJson(res, 400, { error: '快照号不合法' });
|
|
8093
|
+
return;
|
|
8094
|
+
}
|
|
8095
|
+
if (!gitChanges.isAcceptableDiffPath(filePath)) {
|
|
8096
|
+
sendJson(res, 400, { error: '文件路径不合法' });
|
|
8097
|
+
return;
|
|
8098
|
+
}
|
|
8099
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
8100
|
+
const workDir = resolveWebSessionWorkDir(state, sessionRef);
|
|
8101
|
+
// 只给这两个快照之间出现过的文件看 diff:路径由服务端重新取一次核对,不信任请求
|
|
8102
|
+
const listed = webSnapshots.parseRoundChangesOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(workDir, from, to), 30000));
|
|
8103
|
+
if (!listed.files.some(file => file.path === filePath)) {
|
|
8104
|
+
sendJson(res, 404, { error: '该文件不在这一轮的改动里' });
|
|
8105
|
+
return;
|
|
8106
|
+
}
|
|
8107
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundDiffCommand(workDir, from, to, filePath), 30000);
|
|
8108
|
+
sendJson(res, 200, { path: filePath, ...webSnapshots.parseRoundDiffOutput(out) });
|
|
8109
|
+
}
|
|
8110
|
+
},
|
|
8111
|
+
{
|
|
8112
|
+
// 回退前预览:会改 / 删 / 恢复哪些文件(会先拍一张“现在”的快照,不改任何文件)
|
|
8113
|
+
method: 'POST',
|
|
8114
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind\/preview$/),
|
|
8115
|
+
handler: async match => {
|
|
8116
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8117
|
+
if (!sessionRef) {
|
|
8118
|
+
return;
|
|
8119
|
+
}
|
|
8120
|
+
const payload = await readJsonBody(req);
|
|
8121
|
+
const target = findWebRewindTarget(state, sessionRef, payload.messageId);
|
|
8122
|
+
if (target.error) {
|
|
8123
|
+
sendJson(res, target.status, { error: target.error });
|
|
8124
|
+
return;
|
|
8125
|
+
}
|
|
8126
|
+
const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
|
|
8127
|
+
target.history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
|
|
8128
|
+
)).agentProgram;
|
|
8129
|
+
const result = { agentProgram: program, precise: program === 'claude', files: null };
|
|
8130
|
+
if (webSnapshots.isSnapId(target.message.snapBefore)) {
|
|
8131
|
+
const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestorePreviewCommand(resolveWebSessionWorkDir(state, sessionRef), target.message.snapBefore), 30000);
|
|
8132
|
+
const preview = webSnapshots.parseRestorePreviewOutput(out);
|
|
8133
|
+
result.files = preview.ok ? preview : { ok: false, reason: preview.reason };
|
|
8134
|
+
}
|
|
8135
|
+
sendJson(res, 200, result);
|
|
8136
|
+
}
|
|
8137
|
+
},
|
|
8138
|
+
{
|
|
8139
|
+
// 回退:scope = both(对话和文件)/ conversation(只对话)/ files(只文件)
|
|
8140
|
+
method: 'POST',
|
|
8141
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind$/),
|
|
8142
|
+
handler: async match => {
|
|
8143
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8144
|
+
if (!sessionRef) {
|
|
8145
|
+
return;
|
|
8146
|
+
}
|
|
8147
|
+
const payload = await readJsonBody(req);
|
|
8148
|
+
const scope = String(payload.scope || '');
|
|
8149
|
+
if (!['both', 'conversation', 'files'].includes(scope)) {
|
|
8150
|
+
sendJson(res, 400, { error: 'scope 只能是 both、conversation 或 files' });
|
|
8151
|
+
return;
|
|
8152
|
+
}
|
|
8153
|
+
const outcome = await rewindWebSession(ctx, state, sessionRef, payload.messageId, scope);
|
|
8154
|
+
if (outcome.error) {
|
|
8155
|
+
sendJson(res, outcome.status, { error: outcome.error });
|
|
8156
|
+
return;
|
|
8157
|
+
}
|
|
8158
|
+
sendJson(res, 200, outcome);
|
|
8159
|
+
}
|
|
8160
|
+
},
|
|
8161
|
+
{
|
|
8162
|
+
method: 'POST',
|
|
8163
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/hide$/),
|
|
8164
|
+
handler: async match => {
|
|
8165
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8166
|
+
if (!sessionRef) {
|
|
8167
|
+
return;
|
|
8168
|
+
}
|
|
8169
|
+
const payload = await readJsonBody(req);
|
|
8170
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
8171
|
+
sendJson(res, 409, { error: '任务进行中,暂时不能隐藏' });
|
|
8172
|
+
return;
|
|
8173
|
+
}
|
|
8174
|
+
const changed = setWebSessionTurnHidden(state.webHistoryDir, sessionRef, String(payload.messageId || ''), payload.hidden !== false);
|
|
8175
|
+
if (!changed) {
|
|
8176
|
+
sendJson(res, 404, { error: '没有找到这一组' });
|
|
8177
|
+
return;
|
|
8178
|
+
}
|
|
8179
|
+
sendJson(res, 200, { ok: true, count: changed });
|
|
8180
|
+
}
|
|
8181
|
+
},
|
|
7323
8182
|
{
|
|
7324
8183
|
// “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
|
|
7325
8184
|
method: 'GET',
|
|
@@ -7642,6 +8501,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7642
8501
|
}
|
|
7643
8502
|
|
|
7644
8503
|
const payload = await readJsonBody(req);
|
|
8504
|
+
let streamImages;
|
|
8505
|
+
try {
|
|
8506
|
+
streamImages = webUploads.parseImages(payload.images);
|
|
8507
|
+
} catch (e) {
|
|
8508
|
+
sendJson(res, 400, { error: e.message });
|
|
8509
|
+
return;
|
|
8510
|
+
}
|
|
7645
8511
|
const prompt = (payload.prompt || '').trim();
|
|
7646
8512
|
if (!prompt) {
|
|
7647
8513
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
@@ -7654,7 +8520,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7654
8520
|
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
|
|
7655
8521
|
return;
|
|
7656
8522
|
}
|
|
7657
|
-
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
|
|
8523
|
+
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages);
|
|
7658
8524
|
if (turn.busy) {
|
|
7659
8525
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
7660
8526
|
} else if (turn.error) {
|
|
@@ -7671,6 +8537,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7671
8537
|
return;
|
|
7672
8538
|
}
|
|
7673
8539
|
const payload = await readJsonBody(req);
|
|
8540
|
+
let queueImages;
|
|
8541
|
+
try {
|
|
8542
|
+
queueImages = webUploads.parseImages(payload.images);
|
|
8543
|
+
} catch (e) {
|
|
8544
|
+
sendJson(res, 400, { error: e.message });
|
|
8545
|
+
return;
|
|
8546
|
+
}
|
|
7674
8547
|
const text = (payload.prompt || '').trim();
|
|
7675
8548
|
if (!text) {
|
|
7676
8549
|
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
@@ -7696,7 +8569,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7696
8569
|
// 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
|
|
7697
8570
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
7698
8571
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
7699
|
-
const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
|
|
8572
|
+
const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when, images: queueImages });
|
|
7700
8573
|
if (!added.ok) {
|
|
7701
8574
|
sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
|
|
7702
8575
|
return;
|
|
@@ -7820,7 +8693,16 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7820
8693
|
|
|
7821
8694
|
const payload = await readJsonBody(req);
|
|
7822
8695
|
const removeHistory = payload.removeHistory === true;
|
|
8696
|
+
// 先记下工作目录:历史删掉后就查不到了
|
|
8697
|
+
const removedHistoryInfo = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8698
|
+
const workDirHint = (removedHistoryInfo.applied && removedHistoryInfo.applied.hostPath) ||
|
|
8699
|
+
(removedHistoryInfo.runtimeSnapshot && removedHistoryInfo.runtimeSnapshot.hostPath) || '';
|
|
7823
8700
|
const { removedContainer } = removeContainerIdempotent(ctx, sessionRef.containerName);
|
|
8701
|
+
let removedWorkDir = false;
|
|
8702
|
+
if (removeHistory) {
|
|
8703
|
+
// 连同历史一起删时,顺手清掉空的工作目录(只 rmdir,非空删不动)
|
|
8704
|
+
removedWorkDir = await webStorage.removeEmptyWorkDir({ hostPath: workDirHint, ...getStorageRootsConfig(ctx, state) }).catch(() => false);
|
|
8705
|
+
}
|
|
7824
8706
|
|
|
7825
8707
|
if (removeHistory) {
|
|
7826
8708
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
@@ -7833,6 +8715,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7833
8715
|
sendJson(res, 200, {
|
|
7834
8716
|
removed: true,
|
|
7835
8717
|
removedHistory: removeHistory,
|
|
8718
|
+
removedWorkDir,
|
|
7836
8719
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
|
|
7837
8720
|
});
|
|
7838
8721
|
}
|
|
@@ -7975,6 +8858,7 @@ async function startWebServer(options) {
|
|
|
7975
8858
|
? options.agentRawOutputMaxChars
|
|
7976
8859
|
: AGENT_RAW_OUTPUT_MAX_CHARS,
|
|
7977
8860
|
// 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
|
|
8861
|
+
orphanPollMs: Number.isFinite(options.orphanPollMs) && options.orphanPollMs > 0 ? options.orphanPollMs : WEB_ORPHAN_POLL_MS,
|
|
7978
8862
|
agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
|
|
7979
8863
|
? options.agentStepFallbackMs
|
|
7980
8864
|
: agentQueue.STEP_FALLBACK_MS,
|
|
@@ -7993,7 +8877,15 @@ async function startWebServer(options) {
|
|
|
7993
8877
|
const state = {
|
|
7994
8878
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
7995
8879
|
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
7996
|
-
|
|
8880
|
+
orphanTimers: new Set(),
|
|
8881
|
+
authStore: createAuthStore({
|
|
8882
|
+
file: options.authSessionsFile || '',
|
|
8883
|
+
user: ctx.authUser,
|
|
8884
|
+
pass: ctx.authPass,
|
|
8885
|
+
ttlMs: WEB_AUTH_TTL_SECONDS * 1000,
|
|
8886
|
+
now: Date.now,
|
|
8887
|
+
warn: message => ctx.logger.warn(message)
|
|
8888
|
+
}),
|
|
7997
8889
|
terminalSessions: new Map(),
|
|
7998
8890
|
agentRuns: new Map(),
|
|
7999
8891
|
// imageRef -> { status: 'pulling'|'failed', message, promise }
|
|
@@ -8206,6 +9098,7 @@ async function startWebServer(options) {
|
|
|
8206
9098
|
});
|
|
8207
9099
|
|
|
8208
9100
|
startStaleSetupCleanup(ctx);
|
|
9101
|
+
state.orphanReconcileReady = reconcileOrphanTurns(ctx, state).catch(e => ctx.logger.warn('orphan reconcile failed', { message: e && e.message }));
|
|
8209
9102
|
startContainerNetworkWatch(ctx, state);
|
|
8210
9103
|
if (ctx.autoPullImage) {
|
|
8211
9104
|
startWebImagePrepare(ctx, state);
|
|
@@ -8219,6 +9112,9 @@ async function startWebServer(options) {
|
|
|
8219
9112
|
close: () => new Promise(resolve => {
|
|
8220
9113
|
ctx.logger.info('web server closing');
|
|
8221
9114
|
flushWebSessionHistories();
|
|
9115
|
+
state.authStore.close();
|
|
9116
|
+
state.orphanTimers.forEach(timer => clearInterval(timer));
|
|
9117
|
+
state.orphanTimers.clear();
|
|
8222
9118
|
if (state.networkWatch) {
|
|
8223
9119
|
state.networkWatch.stop();
|
|
8224
9120
|
state.networkWatch = null;
|