@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/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
- const nowText = typeof formatDate === 'function' ? formatDate() : '';
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, ctx.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
- hostPath: ctx.hostPath,
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
- hostPath: ctx.hostPath,
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
- ? buildStaticContainerRuntime(ctx, containerInput)
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 || ctx.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
  }