@xcanwin/manyoyo 8.6.12 → 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/bin/manyoyo.js +12 -12
- package/lib/container-network.js +1 -0
- package/lib/name-template.js +52 -0
- package/lib/runtime-resolver.js +13 -1
- package/lib/web/index.html +84 -84
- package/lib/web/server.js +177 -7
- package/lib/web/storage.js +444 -0
- package/package.json +3 -1
package/lib/web/server.js
CHANGED
|
@@ -34,6 +34,7 @@ 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');
|
|
@@ -41,6 +42,7 @@ const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirro
|
|
|
41
42
|
const { createImageAgentProbe } = require('../image-agents');
|
|
42
43
|
const { createAuthStore } = require('./auth-store');
|
|
43
44
|
const webUploads = require('./uploads');
|
|
45
|
+
const webStorage = require('./storage');
|
|
44
46
|
const webSnapshots = require('./snapshots');
|
|
45
47
|
const webRewind = require('./rewind');
|
|
46
48
|
const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
|
|
@@ -2978,8 +2980,7 @@ function resolveNowTemplate(value, formatDate) {
|
|
|
2978
2980
|
if (typeof value !== 'string') {
|
|
2979
2981
|
return value;
|
|
2980
2982
|
}
|
|
2981
|
-
|
|
2982
|
-
return value.replace(/\{now\}|\$\{now\}/g, nowText);
|
|
2983
|
+
return expandTemplate(value, { now: typeof formatDate === 'function' ? formatDate() : '' }).value;
|
|
2983
2984
|
}
|
|
2984
2985
|
|
|
2985
2986
|
function validateContainerNameStrict(containerName) {
|
|
@@ -3236,7 +3237,7 @@ function buildConfigDefaults(ctx, config) {
|
|
|
3236
3237
|
const parsed = toPlainObject(config);
|
|
3237
3238
|
const defaults = {
|
|
3238
3239
|
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
3239
|
-
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath
|
|
3240
|
+
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath)),
|
|
3240
3241
|
containerPath: pickFirstString(parsed.containerPath),
|
|
3241
3242
|
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
3242
3243
|
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
@@ -3284,7 +3285,8 @@ function buildConfigDefaults(ctx, config) {
|
|
|
3284
3285
|
function buildStaticContainerRuntime(ctx, containerName) {
|
|
3285
3286
|
return {
|
|
3286
3287
|
containerName,
|
|
3287
|
-
|
|
3288
|
+
// 只给已存在的容器用;不带 serve 的启动目录,万一容器在这之间被删也不会按它重建
|
|
3289
|
+
hostPath: '',
|
|
3288
3290
|
containerPath: ctx.containerPath,
|
|
3289
3291
|
imageName: ctx.imageName,
|
|
3290
3292
|
imageVersion: ctx.imageVersion,
|
|
@@ -3374,7 +3376,8 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3374
3376
|
globalFirstConfig,
|
|
3375
3377
|
runFirstConfig,
|
|
3376
3378
|
defaults: {
|
|
3377
|
-
|
|
3379
|
+
// 不用 ctx.hostPath(serve 的启动目录):与前端新建容器对话框一致,按模板在真正创建时才建目录
|
|
3380
|
+
hostPath: DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
|
|
3378
3381
|
containerName: `my-${ctx.formatDate()}`,
|
|
3379
3382
|
// 不用 ctx.containerPath:那是 serve 进程的启动目录(CLI 的 cwd 语义),与用户要挂载的目录无关;
|
|
3380
3383
|
// 没配置也没传时落到下面的 `|| hostPath`,容器内路径与宿主机路径一致
|
|
@@ -3388,6 +3391,11 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3388
3391
|
isServerStopMode: false,
|
|
3389
3392
|
pickConfigValue: pickFirstString,
|
|
3390
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
|
+
},
|
|
3391
3399
|
normalizeCommandSuffix: value => {
|
|
3392
3400
|
const text = String(value || '').trim();
|
|
3393
3401
|
return text ? ` ${text}` : '';
|
|
@@ -3413,6 +3421,10 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3413
3421
|
validateContainerNameStrict(containerName);
|
|
3414
3422
|
|
|
3415
3423
|
const hostPath = resolvedBase.hostPath;
|
|
3424
|
+
// 按模板({now} / {quickChatPath})生成的目录:此刻才建
|
|
3425
|
+
if (resolvedBase.hostPathTemplated && !fs.existsSync(hostPath)) {
|
|
3426
|
+
fs.mkdirSync(hostPath, { recursive: true });
|
|
3427
|
+
}
|
|
3416
3428
|
if (typeof ctx.validateHostPath === 'function') {
|
|
3417
3429
|
ctx.validateHostPath(hostPath);
|
|
3418
3430
|
} else {
|
|
@@ -4131,9 +4143,26 @@ function getWebNetworkManager(ctx, state) {
|
|
|
4131
4143
|
|
|
4132
4144
|
// 进入容器前一律检查网络规则(宿主机侧的“本次启动已下发”状态):文件浏览也会 exec /bin/bash -lc,
|
|
4133
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
|
+
|
|
4134
4163
|
async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
|
|
4135
4164
|
const runtime = typeof containerInput === 'string'
|
|
4136
|
-
?
|
|
4165
|
+
? resolveRuntimeForExistingName(ctx, state, containerInput)
|
|
4137
4166
|
: containerInput;
|
|
4138
4167
|
|
|
4139
4168
|
if (!runtime || !runtime.containerName) {
|
|
@@ -4234,6 +4263,8 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4234
4263
|
await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
|
|
4235
4264
|
}
|
|
4236
4265
|
|
|
4266
|
+
const DEFAULT_NEW_CONTAINER_NAME_TEMPLATE = 'my-easy-{now}';
|
|
4267
|
+
const DEFAULT_NEW_CONTAINER_PATH_TEMPLATE = '{quickChatPath}/{now}';
|
|
4237
4268
|
const WEB_FIRST_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
4238
4269
|
const WEB_FIRST_COMMAND_OUTPUT_CHARS = 500;
|
|
4239
4270
|
|
|
@@ -5868,6 +5899,38 @@ function getSetupWorkpath(state) {
|
|
|
5868
5899
|
return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
|
|
5869
5900
|
}
|
|
5870
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
|
+
|
|
5871
5934
|
async function inspectSetupImage(ctx, state) {
|
|
5872
5935
|
const image = `${ctx.imageName}:${ctx.imageVersion}`;
|
|
5873
5936
|
const pulling = state && state.imagePulls.get(image);
|
|
@@ -6175,7 +6238,7 @@ function buildSessionFallbackApplied(ctx, state, name, history, summary) {
|
|
|
6175
6238
|
|
|
6176
6239
|
return {
|
|
6177
6240
|
containerName: name,
|
|
6178
|
-
hostPath: defaults.hostPath ||
|
|
6241
|
+
hostPath: defaults.hostPath || '',
|
|
6179
6242
|
containerPath: defaults.containerPath || ctx.containerPath || '',
|
|
6180
6243
|
imageName: defaults.imageName || ctx.imageName || '',
|
|
6181
6244
|
imageVersion: defaults.imageVersion || ctx.imageVersion || '',
|
|
@@ -6907,6 +6970,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6907
6970
|
});
|
|
6908
6971
|
}
|
|
6909
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
|
+
},
|
|
6910
6990
|
{
|
|
6911
6991
|
// 系统设置“Agent 环境说明”:内置说明(只读)+ 全局配置里用户追加的说明
|
|
6912
6992
|
method: 'GET',
|
|
@@ -7612,6 +7692,86 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7612
7692
|
sendJson(res, 200, payload);
|
|
7613
7693
|
}
|
|
7614
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
|
+
},
|
|
7615
7775
|
{
|
|
7616
7776
|
// 聊天里的图片:原始请求体,类型只收 png/jpeg/gif/webp,单张 10MB
|
|
7617
7777
|
method: 'POST',
|
|
@@ -8533,7 +8693,16 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8533
8693
|
|
|
8534
8694
|
const payload = await readJsonBody(req);
|
|
8535
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) || '';
|
|
8536
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
|
+
}
|
|
8537
8706
|
|
|
8538
8707
|
if (removeHistory) {
|
|
8539
8708
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
@@ -8546,6 +8715,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8546
8715
|
sendJson(res, 200, {
|
|
8547
8716
|
removed: true,
|
|
8548
8717
|
removedHistory: removeHistory,
|
|
8718
|
+
removedWorkDir,
|
|
8549
8719
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
|
|
8550
8720
|
});
|
|
8551
8721
|
}
|