@xcanwin/manyoyo 8.6.12 → 8.7.1
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/agent-adapters/index.js +23 -6
- package/lib/container-network.js +1 -0
- package/lib/init-config.js +4 -1
- package/lib/json5-text-edit.js +51 -0
- package/lib/model-service-containers.js +63 -0
- package/lib/model-service-import.js +98 -0
- package/lib/model-services.js +313 -0
- package/lib/name-template.js +52 -0
- package/lib/provider-presets.js +45 -15
- package/lib/runtime-resolver.js +13 -1
- package/lib/setup-cli.js +18 -5
- package/lib/setup-config.js +6 -1
- package/lib/setup.js +52 -5
- package/lib/web/index.html +87 -85
- package/lib/web/model-services.js +248 -0
- package/lib/web/server.js +274 -84
- package/lib/web/storage.js +444 -0
- package/manyoyo.example.json +3 -1
- package/package.json +3 -1
package/lib/web/server.js
CHANGED
|
@@ -27,6 +27,8 @@ const { createDeniedNotifier } = require('./denied-notify');
|
|
|
27
27
|
const { domainRuleAllows } = require('../network-policy');
|
|
28
28
|
const { createPortForwarder } = require('../port-forward');
|
|
29
29
|
const { createContainerManageRoutes, pendingRisks, STALE_PAGE_MESSAGE } = require('./container-manage');
|
|
30
|
+
const { createModelServiceRoutes } = require('./model-services');
|
|
31
|
+
const { thirdPartyCodexModels } = require('../provider-presets');
|
|
30
32
|
const { resolveManageOptions } = require('../container-manage-options');
|
|
31
33
|
const { defaultPolicy, usesProxy } = require('../network-policy');
|
|
32
34
|
const { normalizeMirrors } = require('../runtime-normalizers');
|
|
@@ -34,13 +36,15 @@ const { estimateContainerCapacity } = require('../capacity');
|
|
|
34
36
|
const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
|
|
35
37
|
const { findValueRangeByPath, applyTextReplacements, upsertValueByPath } = require('../json5-text-edit');
|
|
36
38
|
const { resolveRuntimeConfig } = require('../runtime-resolver');
|
|
39
|
+
const { resolveQuickChatPath, expandTemplate } = require('../name-template');
|
|
37
40
|
const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
38
41
|
const { ensureImagePresent, pullImageProcess } = require('../image-pull');
|
|
39
42
|
const { waitForImport } = require('../offline-import');
|
|
40
|
-
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
|
|
43
|
+
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw, buildSetupSkippedConfigRaw } = require('../setup-config');
|
|
41
44
|
const { createImageAgentProbe } = require('../image-agents');
|
|
42
45
|
const { createAuthStore } = require('./auth-store');
|
|
43
46
|
const webUploads = require('./uploads');
|
|
47
|
+
const webStorage = require('./storage');
|
|
44
48
|
const webSnapshots = require('./snapshots');
|
|
45
49
|
const webRewind = require('./rewind');
|
|
46
50
|
const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
|
|
@@ -585,6 +589,26 @@ function createWebSessionMessageId() {
|
|
|
585
589
|
return `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
586
590
|
}
|
|
587
591
|
|
|
592
|
+
// 容器状态类的系统消息(已创建并启动 / 已启动 / 首次命令已执行)只在会话已经有对话时才写:
|
|
593
|
+
// 新会话一建好就写一条,聊天页就不是空的,输入框不再居中;对话中途重建或启动容器时才值得告诉用户
|
|
594
|
+
function appendWebContainerNotice(webHistoryDir, sessionRefOrContainerName, content) {
|
|
595
|
+
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
596
|
+
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
597
|
+
: sessionRefOrContainerName;
|
|
598
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
599
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
600
|
+
if (agentSession.messages.length) {
|
|
601
|
+
appendWebSessionMessage(webHistoryDir, sessionRef, 'system', content);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// 不写消息,但新建的会话照样要落盘(列表里是真的 AGENT,不是现算出来的)
|
|
605
|
+
const timestamp = new Date().toISOString();
|
|
606
|
+
if (!agentSession.createdAt) agentSession.createdAt = timestamp;
|
|
607
|
+
agentSession.updatedAt = timestamp;
|
|
608
|
+
history.updatedAt = timestamp;
|
|
609
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
610
|
+
}
|
|
611
|
+
|
|
588
612
|
function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role, content, extra = {}) {
|
|
589
613
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
590
614
|
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
@@ -758,33 +782,6 @@ function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
|
|
|
758
782
|
return removed;
|
|
759
783
|
}
|
|
760
784
|
|
|
761
|
-
// 从页面隐藏 / 取消隐藏某一组(用户消息 + 它的执行过程 + 回复):只改展示,agent 仍记得
|
|
762
|
-
function setWebSessionTurnHidden(webHistoryDir, sessionRef, userMessageId, hidden) {
|
|
763
|
-
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
764
|
-
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
765
|
-
if (!agentSession) {
|
|
766
|
-
return 0;
|
|
767
|
-
}
|
|
768
|
-
const start = agentSession.messages.findIndex(item => item && String(item.id || '') === String(userMessageId) && item.role === 'user');
|
|
769
|
-
if (start === -1) {
|
|
770
|
-
return 0;
|
|
771
|
-
}
|
|
772
|
-
let end = start + 1;
|
|
773
|
-
while (end < agentSession.messages.length && agentSession.messages[end].role !== 'user') {
|
|
774
|
-
end += 1;
|
|
775
|
-
}
|
|
776
|
-
for (let i = start; i < end; i += 1) {
|
|
777
|
-
if (hidden) agentSession.messages[i].hidden = true;
|
|
778
|
-
else delete agentSession.messages[i].hidden;
|
|
779
|
-
}
|
|
780
|
-
agentSession.epoch = messagesView.createEpoch();
|
|
781
|
-
const timestamp = new Date().toISOString();
|
|
782
|
-
agentSession.updatedAt = timestamp;
|
|
783
|
-
history.updatedAt = timestamp;
|
|
784
|
-
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
785
|
-
return end - start;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
785
|
function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
|
|
789
786
|
const history = loadWebSessionHistory(webHistoryDir, containerName);
|
|
790
787
|
history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
|
|
@@ -1030,9 +1027,8 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
1030
1027
|
}
|
|
1031
1028
|
}
|
|
1032
1029
|
// 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
: renderAgentPromptCommand(codexTemplate, prompt));
|
|
1030
|
+
// 先给模板加占位再渲染提示词:用户的提示词里写了 model_provider / --model 之类的字也不会让注入失效
|
|
1031
|
+
return renderAgentPromptCommand(withCodexBaseUrl(codexTemplate), prompt);
|
|
1036
1032
|
}
|
|
1037
1033
|
|
|
1038
1034
|
function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
@@ -2978,8 +2974,7 @@ function resolveNowTemplate(value, formatDate) {
|
|
|
2978
2974
|
if (typeof value !== 'string') {
|
|
2979
2975
|
return value;
|
|
2980
2976
|
}
|
|
2981
|
-
|
|
2982
|
-
return value.replace(/\{now\}|\$\{now\}/g, nowText);
|
|
2977
|
+
return expandTemplate(value, { now: typeof formatDate === 'function' ? formatDate() : '' }).value;
|
|
2983
2978
|
}
|
|
2984
2979
|
|
|
2985
2980
|
function validateContainerNameStrict(containerName) {
|
|
@@ -3236,7 +3231,7 @@ function buildConfigDefaults(ctx, config) {
|
|
|
3236
3231
|
const parsed = toPlainObject(config);
|
|
3237
3232
|
const defaults = {
|
|
3238
3233
|
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
3239
|
-
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath
|
|
3234
|
+
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath)),
|
|
3240
3235
|
containerPath: pickFirstString(parsed.containerPath),
|
|
3241
3236
|
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
3242
3237
|
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
@@ -3284,7 +3279,8 @@ function buildConfigDefaults(ctx, config) {
|
|
|
3284
3279
|
function buildStaticContainerRuntime(ctx, containerName) {
|
|
3285
3280
|
return {
|
|
3286
3281
|
containerName,
|
|
3287
|
-
|
|
3282
|
+
// 只给已存在的容器用;不带 serve 的启动目录,万一容器在这之间被删也不会按它重建
|
|
3283
|
+
hostPath: '',
|
|
3288
3284
|
containerPath: ctx.containerPath,
|
|
3289
3285
|
imageName: ctx.imageName,
|
|
3290
3286
|
imageVersion: ctx.imageVersion,
|
|
@@ -3374,7 +3370,8 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3374
3370
|
globalFirstConfig,
|
|
3375
3371
|
runFirstConfig,
|
|
3376
3372
|
defaults: {
|
|
3377
|
-
|
|
3373
|
+
// 不用 ctx.hostPath(serve 的启动目录):与前端新建容器对话框一致,按模板在真正创建时才建目录
|
|
3374
|
+
hostPath: DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
|
|
3378
3375
|
containerName: `my-${ctx.formatDate()}`,
|
|
3379
3376
|
// 不用 ctx.containerPath:那是 serve 进程的启动目录(CLI 的 cwd 语义),与用户要挂载的目录无关;
|
|
3380
3377
|
// 没配置也没传时落到下面的 `|| hostPath`,容器内路径与宿主机路径一致
|
|
@@ -3388,6 +3385,11 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3388
3385
|
isServerStopMode: false,
|
|
3389
3386
|
pickConfigValue: pickFirstString,
|
|
3390
3387
|
resolveContainerNameTemplate: value => resolveNowTemplate(value, ctx.formatDate),
|
|
3388
|
+
// {now} / {quickChatPath} 一次解析只取一个时间:容器名和目录用同一个时间戳
|
|
3389
|
+
templateVars: {
|
|
3390
|
+
now: typeof ctx.formatDate === 'function' ? ctx.formatDate() : undefined,
|
|
3391
|
+
quickChatPath: resolveQuickChatPath(config, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) })
|
|
3392
|
+
},
|
|
3391
3393
|
normalizeCommandSuffix: value => {
|
|
3392
3394
|
const text = String(value || '').trim();
|
|
3393
3395
|
return text ? ` ${text}` : '';
|
|
@@ -3413,6 +3415,10 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
3413
3415
|
validateContainerNameStrict(containerName);
|
|
3414
3416
|
|
|
3415
3417
|
const hostPath = resolvedBase.hostPath;
|
|
3418
|
+
// 按模板({now} / {quickChatPath})生成的目录:此刻才建
|
|
3419
|
+
if (resolvedBase.hostPathTemplated && !fs.existsSync(hostPath)) {
|
|
3420
|
+
fs.mkdirSync(hostPath, { recursive: true });
|
|
3421
|
+
}
|
|
3416
3422
|
if (typeof ctx.validateHostPath === 'function') {
|
|
3417
3423
|
ctx.validateHostPath(hostPath);
|
|
3418
3424
|
} else {
|
|
@@ -4131,9 +4137,26 @@ function getWebNetworkManager(ctx, state) {
|
|
|
4131
4137
|
|
|
4132
4138
|
// 进入容器前一律检查网络规则(宿主机侧的“本次启动已下发”状态):文件浏览也会 exec /bin/bash -lc,
|
|
4133
4139
|
// 里面的 .bashrc 等是 Agent 能改的代码,不能在规则缺失的窗口里跑
|
|
4140
|
+
// 只给名字时:容器还在就不需要运行配置;容器已被删掉,且历史里有创建参数,就按它重建(走 buildCreateRuntime 的路径校验)。
|
|
4141
|
+
// 不再无条件退回 serve 进程的启动目录:那会让重建的容器挂上 $HOME,并且没有原来的 env 和网络设置;
|
|
4142
|
+
// 没有历史也没有容器时直接报错,不再猜参数
|
|
4143
|
+
function resolveRuntimeForExistingName(ctx, state, containerName) {
|
|
4144
|
+
if (ctx.containerExists(containerName)) {
|
|
4145
|
+
return buildStaticContainerRuntime(ctx, containerName);
|
|
4146
|
+
}
|
|
4147
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
4148
|
+
const snapshot = history.runtimeSnapshot && typeof history.runtimeSnapshot === 'object' ? history.runtimeSnapshot : null;
|
|
4149
|
+
if (!snapshot || !snapshot.hostPath) {
|
|
4150
|
+
throw new Error('这个会话没有建容器的参数,请新建容器');
|
|
4151
|
+
}
|
|
4152
|
+
const createOptions = buildCloneCreateOptionsFromSnapshot(snapshot, history);
|
|
4153
|
+
createOptions.containerName = containerName;
|
|
4154
|
+
return buildCreateRuntime(ctx, state, { createOptions });
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4134
4157
|
async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
|
|
4135
4158
|
const runtime = typeof containerInput === 'string'
|
|
4136
|
-
?
|
|
4159
|
+
? resolveRuntimeForExistingName(ctx, state, containerInput)
|
|
4137
4160
|
: containerInput;
|
|
4138
4161
|
|
|
4139
4162
|
if (!runtime || !runtime.containerName) {
|
|
@@ -4203,20 +4226,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4203
4226
|
mirrors,
|
|
4204
4227
|
warn: message => console.warn(message)
|
|
4205
4228
|
});
|
|
4206
|
-
|
|
4207
|
-
state.webHistoryDir,
|
|
4208
|
-
messageSessionRef || runtime.containerName,
|
|
4209
|
-
'system',
|
|
4210
|
-
`容器 ${runtime.containerName} 已创建并启动。`
|
|
4211
|
-
);
|
|
4229
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已创建并启动。`);
|
|
4212
4230
|
if (runtime.first) {
|
|
4213
4231
|
const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
|
|
4214
|
-
|
|
4215
|
-
state.webHistoryDir,
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4232
|
+
if (firstResult.exitCode === 0) {
|
|
4233
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, '首次命令已执行');
|
|
4234
|
+
} else {
|
|
4235
|
+
// 失败一律写:用户必须看到
|
|
4236
|
+
appendWebSessionMessage(state.webHistoryDir, messageSessionRef || runtime.containerName, 'system', formatWebFirstFailure(firstResult, runtime.first));
|
|
4237
|
+
}
|
|
4220
4238
|
}
|
|
4221
4239
|
return;
|
|
4222
4240
|
}
|
|
@@ -4224,16 +4242,13 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4224
4242
|
const status = ctx.getContainerStatus(runtime.containerName);
|
|
4225
4243
|
if (status !== 'running') {
|
|
4226
4244
|
ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
|
|
4227
|
-
|
|
4228
|
-
state.webHistoryDir,
|
|
4229
|
-
messageSessionRef || runtime.containerName,
|
|
4230
|
-
'system',
|
|
4231
|
-
`容器 ${runtime.containerName} 已启动。`
|
|
4232
|
-
);
|
|
4245
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已启动。`);
|
|
4233
4246
|
}
|
|
4234
4247
|
await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
|
|
4235
4248
|
}
|
|
4236
4249
|
|
|
4250
|
+
const DEFAULT_NEW_CONTAINER_NAME_TEMPLATE = 'my-easy-{now}';
|
|
4251
|
+
const DEFAULT_NEW_CONTAINER_PATH_TEMPLATE = '{quickChatPath}/{now}';
|
|
4237
4252
|
const WEB_FIRST_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
4238
4253
|
const WEB_FIRST_COMMAND_OUTPUT_CHARS = 500;
|
|
4239
4254
|
|
|
@@ -5862,12 +5877,47 @@ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
|
|
|
5862
5877
|
}
|
|
5863
5878
|
|
|
5864
5879
|
const SETUP_TEST_TIMEOUT_SECONDS = 60;
|
|
5865
|
-
|
|
5880
|
+
// 要求真的调一次工具:只回一句 OK 发现不了“能连通但调不了工具”(智谱 + Claude Code 工具搜索就是这样)。
|
|
5881
|
+
// 命令文本里没有 42,算出来的结果只会来自真的执行
|
|
5882
|
+
const SETUP_TEST_PROMPT = 'Run the shell command `echo mp-$((6*7))` and reply with its exact output only.';
|
|
5883
|
+
const SETUP_TEST_EXPECT_OUTPUT = 'mp-42';
|
|
5866
5884
|
|
|
5867
5885
|
function getSetupWorkpath(state) {
|
|
5868
5886
|
return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
|
|
5869
5887
|
}
|
|
5870
5888
|
|
|
5889
|
+
// 工作目录根:默认的 ~/.manyoyo/work;配置里的快捷对话目录(serve.quickChat.path)另算,storage 里会先校验再用
|
|
5890
|
+
function getStorageRootsConfig(ctx, state) {
|
|
5891
|
+
const extraWorkRoots = [];
|
|
5892
|
+
const configHostPaths = [];
|
|
5893
|
+
try {
|
|
5894
|
+
const parsed = readWebConfigSnapshot(state.webConfigPath).parsed;
|
|
5895
|
+
// 配置里写死的挂载目录(全局与每个 runs.<名>):删除时算在用;含 {模板} 的每次不同,不算
|
|
5896
|
+
const fixedHostPaths = [parsed && parsed.hostPath, ...Object.values((parsed && parsed.runs) || {}).map(run => run && run.hostPath)];
|
|
5897
|
+
for (const value of fixedHostPaths) {
|
|
5898
|
+
if (typeof value === 'string' && value.trim() && !value.includes('{')) configHostPaths.push(expandHomeAliasPath(value.trim()));
|
|
5899
|
+
}
|
|
5900
|
+
const quickChatPath = parsed && parsed.serve && parsed.serve.quickChat && parsed.serve.quickChat.path;
|
|
5901
|
+
if (typeof quickChatPath === 'string' && quickChatPath.trim()) extraWorkRoots.push(expandHomeAliasPath(quickChatPath.trim()));
|
|
5902
|
+
} catch (e) { /* 配置读不出来就只认默认目录 */ }
|
|
5903
|
+
return {
|
|
5904
|
+
workRoots: [getSetupWorkpath(state)],
|
|
5905
|
+
extraWorkRoots,
|
|
5906
|
+
configHostPaths,
|
|
5907
|
+
homeDir: ctx.homeDir || os.homedir(),
|
|
5908
|
+
dockerExecAsync: ctx.dockerExecAsync || (async (args) => ctx.dockerExecArgs(args))
|
|
5909
|
+
};
|
|
5910
|
+
}
|
|
5911
|
+
|
|
5912
|
+
async function loadStorageListing(ctx, state) {
|
|
5913
|
+
const listing = await webStorage.buildStorageListing({
|
|
5914
|
+
...getStorageRootsConfig(ctx, state),
|
|
5915
|
+
historyDir: state.webHistoryDir
|
|
5916
|
+
});
|
|
5917
|
+
state.storageKnownPaths = new Set(listing.rows.map(row => row.path));
|
|
5918
|
+
return listing;
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5871
5921
|
async function inspectSetupImage(ctx, state) {
|
|
5872
5922
|
const image = `${ctx.imageName}:${ctx.imageVersion}`;
|
|
5873
5923
|
const pulling = state && state.imagePulls.get(image);
|
|
@@ -5917,7 +5967,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
5917
5967
|
if (!template) {
|
|
5918
5968
|
return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
|
|
5919
5969
|
}
|
|
5920
|
-
const agentCommand = withCodexBaseUrl(
|
|
5970
|
+
const agentCommand = renderAgentPromptCommand(withCodexBaseUrl(template), SETUP_TEST_PROMPT);
|
|
5921
5971
|
const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
|
|
5922
5972
|
|
|
5923
5973
|
const workpath = getSetupWorkpath(state);
|
|
@@ -5955,7 +6005,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
5955
6005
|
})
|
|
5956
6006
|
]);
|
|
5957
6007
|
clearTimeout(timer);
|
|
5958
|
-
return classifyConnectionResult(result, secrets);
|
|
6008
|
+
return classifyConnectionResult(result, secrets, { expectOutput: SETUP_TEST_EXPECT_OUTPUT });
|
|
5959
6009
|
} catch (e) {
|
|
5960
6010
|
return classifyConnectionResult({ exitCode: 1, output: e && e.message ? e.message : '' }, secrets);
|
|
5961
6011
|
} finally {
|
|
@@ -6013,8 +6063,10 @@ async function buildSetupStatus(ctx, state) {
|
|
|
6013
6063
|
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
6014
6064
|
const config = snapshot.parseError ? {} : snapshot.parsed;
|
|
6015
6065
|
const configuredAgents = getConfiguredAgents(config);
|
|
6066
|
+
const setupSkipped = Boolean(config.serve && typeof config.serve === 'object' && config.serve.setupSkipped === true);
|
|
6016
6067
|
return {
|
|
6017
|
-
needsSetup: configuredAgents.length === 0,
|
|
6068
|
+
needsSetup: configuredAgents.length === 0 && !setupSkipped,
|
|
6069
|
+
setupSkipped,
|
|
6018
6070
|
configuredAgents,
|
|
6019
6071
|
serverUser: ctx.authUser,
|
|
6020
6072
|
// 自动生成(用户看不到)的密码不算“已设置”
|
|
@@ -6045,12 +6097,13 @@ function resolveSetupRunInput(ctx, state, payload) {
|
|
|
6045
6097
|
if (!hostPath || !path.isAbsolute(hostPath)) {
|
|
6046
6098
|
throw new Error('hostPath 必须是绝对路径');
|
|
6047
6099
|
}
|
|
6048
|
-
if (hostPath === getSetupWorkpath(state)) fs.mkdirSync(hostPath, { recursive: true });
|
|
6049
6100
|
ctx.validateHostPath(hostPath);
|
|
6050
6101
|
}
|
|
6102
|
+
// 选的就是默认工作目录根:不写进运行配置(新建容器各自建 <根>/<时间> 子目录,见 buildAgentRunProfile)
|
|
6103
|
+
if (hostPath === getSetupWorkpath(state)) hostPath = '';
|
|
6104
|
+
fs.mkdirSync(getSetupWorkpath(state), { recursive: true });
|
|
6051
6105
|
const profile = buildAgentRunProfile(agent, body.env === undefined ? {} : body.env, {
|
|
6052
6106
|
existingRun,
|
|
6053
|
-
defaultHostPath: getSetupWorkpath(state),
|
|
6054
6107
|
hostPath
|
|
6055
6108
|
});
|
|
6056
6109
|
return { agent, snapshot, profile };
|
|
@@ -6175,7 +6228,7 @@ function buildSessionFallbackApplied(ctx, state, name, history, summary) {
|
|
|
6175
6228
|
|
|
6176
6229
|
return {
|
|
6177
6230
|
containerName: name,
|
|
6178
|
-
hostPath: defaults.hostPath ||
|
|
6231
|
+
hostPath: defaults.hostPath || '',
|
|
6179
6232
|
containerPath: defaults.containerPath || ctx.containerPath || '',
|
|
6180
6233
|
imageName: defaults.imageName || ctx.imageName || '',
|
|
6181
6234
|
imageVersion: defaults.imageVersion || ctx.imageVersion || '',
|
|
@@ -6907,6 +6960,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6907
6960
|
});
|
|
6908
6961
|
}
|
|
6909
6962
|
},
|
|
6963
|
+
{
|
|
6964
|
+
// 新建容器对话框的默认值:配置里写了就用配置的,没写就用默认模板
|
|
6965
|
+
// (容器名 my-easy-{now},宿主机目录与容器内目录 {quickChatPath}/{now}),由前端展开;目录在点“创建”时才建
|
|
6966
|
+
method: 'GET',
|
|
6967
|
+
match: currentPath => currentPath === '/api/system/new-container-defaults' ? [] : null,
|
|
6968
|
+
handler: async () => {
|
|
6969
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
6970
|
+
const config = snapshot.parseError ? {} : toPlainObject(snapshot.parsed);
|
|
6971
|
+
const hostPathConfigured = Boolean(pickFirstString(config.hostPath));
|
|
6972
|
+
sendJson(res, 200, {
|
|
6973
|
+
containerName: pickFirstString(config.containerName) || DEFAULT_NEW_CONTAINER_NAME_TEMPLATE,
|
|
6974
|
+
hostPath: pickFirstString(config.hostPath) || DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
|
|
6975
|
+
containerPath: pickFirstString(config.containerPath) || (hostPathConfigured ? '' : DEFAULT_NEW_CONTAINER_PATH_TEMPLATE),
|
|
6976
|
+
quickChatPath: resolveQuickChatPath(config, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) })
|
|
6977
|
+
});
|
|
6978
|
+
}
|
|
6979
|
+
},
|
|
6910
6980
|
{
|
|
6911
6981
|
// 系统设置“Agent 环境说明”:内置说明(只读)+ 全局配置里用户追加的说明
|
|
6912
6982
|
method: 'GET',
|
|
@@ -7089,6 +7159,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7089
7159
|
sendJson(res, 200, { saved: true, agent: input.agent, run: sanitizeSensitiveData(input.profile) });
|
|
7090
7160
|
}
|
|
7091
7161
|
},
|
|
7162
|
+
{
|
|
7163
|
+
method: 'POST',
|
|
7164
|
+
match: currentPath => currentPath === '/api/setup/skip' ? [] : null,
|
|
7165
|
+
handler: async () => {
|
|
7166
|
+
await readJsonBody(req);
|
|
7167
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
7168
|
+
if (snapshot.parseError) {
|
|
7169
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
7170
|
+
return;
|
|
7171
|
+
}
|
|
7172
|
+
let nextRaw;
|
|
7173
|
+
try {
|
|
7174
|
+
nextRaw = buildSetupSkippedConfigRaw(baseRawOf(snapshot));
|
|
7175
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
7176
|
+
} catch (e) {
|
|
7177
|
+
sendJson(res, 400, { error: '配置写入失败', detail: e.message || '' });
|
|
7178
|
+
return;
|
|
7179
|
+
}
|
|
7180
|
+
writeConfigFileSecure(path.resolve(state.webConfigPath), nextRaw);
|
|
7181
|
+
sendJson(res, 200, { saved: true });
|
|
7182
|
+
}
|
|
7183
|
+
},
|
|
7092
7184
|
{
|
|
7093
7185
|
method: 'POST',
|
|
7094
7186
|
match: currentPath => currentPath === '/api/setup/password' ? [] : null,
|
|
@@ -7612,6 +7704,86 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7612
7704
|
sendJson(res, 200, payload);
|
|
7613
7705
|
}
|
|
7614
7706
|
},
|
|
7707
|
+
{
|
|
7708
|
+
// 磁盘占用:宿主机工作目录与已删除容器的历史(体积另走 /size,大目录不拖慢列表)
|
|
7709
|
+
method: 'GET',
|
|
7710
|
+
match: currentPath => currentPath === '/api/storage' ? [] : null,
|
|
7711
|
+
handler: async () => {
|
|
7712
|
+
let listing;
|
|
7713
|
+
try {
|
|
7714
|
+
listing = await loadStorageListing(ctx, state);
|
|
7715
|
+
} catch (e) {
|
|
7716
|
+
sendJson(res, 503, { error: '容器运行时不可用,暂时无法判断目录是否还在使用' });
|
|
7717
|
+
return;
|
|
7718
|
+
}
|
|
7719
|
+
sendJson(res, 200, listing);
|
|
7720
|
+
}
|
|
7721
|
+
},
|
|
7722
|
+
{
|
|
7723
|
+
method: 'GET',
|
|
7724
|
+
match: currentPath => currentPath === '/api/storage/size' ? [] : null,
|
|
7725
|
+
handler: async () => {
|
|
7726
|
+
const target = String(new URL(req.url || '/', 'http://localhost').searchParams.get('path') || '');
|
|
7727
|
+
if (!state.storageKnownPaths || !state.storageKnownPaths.has(target)) {
|
|
7728
|
+
sendJson(res, 404, { error: '没有这个目录,请刷新列表' });
|
|
7729
|
+
return;
|
|
7730
|
+
}
|
|
7731
|
+
sendJson(res, 200, await webStorage.directorySize(target));
|
|
7732
|
+
}
|
|
7733
|
+
},
|
|
7734
|
+
{
|
|
7735
|
+
method: 'GET',
|
|
7736
|
+
match: currentPath => currentPath === '/api/storage/files' ? [] : null,
|
|
7737
|
+
handler: async () => {
|
|
7738
|
+
const params = new URL(req.url || '/', 'http://localhost').searchParams;
|
|
7739
|
+
const target = String(params.get('path') || '');
|
|
7740
|
+
if (!state.storageKnownPaths || !state.storageKnownPaths.has(target)) {
|
|
7741
|
+
sendJson(res, 404, { error: '没有这个目录,请刷新列表' });
|
|
7742
|
+
return;
|
|
7743
|
+
}
|
|
7744
|
+
const listed = await webStorage.listDirectory(target, params.get('sub') || '');
|
|
7745
|
+
sendJson(res, listed.ok ? 200 : listed.status, listed.ok ? listed : { error: listed.error });
|
|
7746
|
+
}
|
|
7747
|
+
},
|
|
7748
|
+
{
|
|
7749
|
+
method: 'DELETE',
|
|
7750
|
+
match: currentPath => currentPath === '/api/storage/dirs' ? [] : null,
|
|
7751
|
+
handler: async () => {
|
|
7752
|
+
const body = (await readJsonBody(req)) || {};
|
|
7753
|
+
let listing;
|
|
7754
|
+
try {
|
|
7755
|
+
listing = await loadStorageListing(ctx, state);
|
|
7756
|
+
} catch (e) {
|
|
7757
|
+
sendJson(res, 503, { error: '容器运行时不可用,不能确认目录没人用,已拒绝删除' });
|
|
7758
|
+
return;
|
|
7759
|
+
}
|
|
7760
|
+
const removed = await webStorage.removeManagedDirectory(listing, String(body.path || ''), { emptyOnly: body.emptyOnly === true });
|
|
7761
|
+
sendJson(res, removed.ok ? 200 : removed.status, removed.ok ? { removed: true } : { error: removed.error });
|
|
7762
|
+
}
|
|
7763
|
+
},
|
|
7764
|
+
{
|
|
7765
|
+
method: 'DELETE',
|
|
7766
|
+
match: currentPath => currentPath === '/api/storage/histories' ? [] : null,
|
|
7767
|
+
handler: async () => {
|
|
7768
|
+
const body = (await readJsonBody(req)) || {};
|
|
7769
|
+
const name = String(body.name || '');
|
|
7770
|
+
if (!ctx.isValidContainerName(name)) {
|
|
7771
|
+
sendJson(res, 400, { error: '名称不合法' });
|
|
7772
|
+
return;
|
|
7773
|
+
}
|
|
7774
|
+
let listing;
|
|
7775
|
+
try {
|
|
7776
|
+
listing = await loadStorageListing(ctx, state);
|
|
7777
|
+
} catch (e) {
|
|
7778
|
+
sendJson(res, 503, { error: '容器运行时不可用,不能确认容器已删除,已拒绝清理' });
|
|
7779
|
+
return;
|
|
7780
|
+
}
|
|
7781
|
+
const removed = await webStorage.removeDeletedHistory(
|
|
7782
|
+
state.webHistoryDir, listing, name, historyName => removeWebSessionHistory(state.webHistoryDir, historyName)
|
|
7783
|
+
);
|
|
7784
|
+
sendJson(res, removed.ok ? 200 : removed.status, removed.ok ? { removed: true } : { error: removed.error });
|
|
7785
|
+
}
|
|
7786
|
+
},
|
|
7615
7787
|
{
|
|
7616
7788
|
// 聊天里的图片:原始请求体,类型只收 png/jpeg/gif/webp,单张 10MB
|
|
7617
7789
|
method: 'POST',
|
|
@@ -7998,27 +8170,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7998
8170
|
sendJson(res, 200, outcome);
|
|
7999
8171
|
}
|
|
8000
8172
|
},
|
|
8001
|
-
{
|
|
8002
|
-
method: 'POST',
|
|
8003
|
-
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/hide$/),
|
|
8004
|
-
handler: async match => {
|
|
8005
|
-
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8006
|
-
if (!sessionRef) {
|
|
8007
|
-
return;
|
|
8008
|
-
}
|
|
8009
|
-
const payload = await readJsonBody(req);
|
|
8010
|
-
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
8011
|
-
sendJson(res, 409, { error: '任务进行中,暂时不能隐藏' });
|
|
8012
|
-
return;
|
|
8013
|
-
}
|
|
8014
|
-
const changed = setWebSessionTurnHidden(state.webHistoryDir, sessionRef, String(payload.messageId || ''), payload.hidden !== false);
|
|
8015
|
-
if (!changed) {
|
|
8016
|
-
sendJson(res, 404, { error: '没有找到这一组' });
|
|
8017
|
-
return;
|
|
8018
|
-
}
|
|
8019
|
-
sendJson(res, 200, { ok: true, count: changed });
|
|
8020
|
-
}
|
|
8021
|
-
},
|
|
8022
8173
|
{
|
|
8023
8174
|
// “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
|
|
8024
8175
|
method: 'GET',
|
|
@@ -8098,7 +8249,14 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8098
8249
|
containerInfo.defaultCommand
|
|
8099
8250
|
);
|
|
8100
8251
|
const agentProgram = resolveAgentProgram(effectiveTemplate);
|
|
8101
|
-
|
|
8252
|
+
let models = null;
|
|
8253
|
+
if (agentProgram === 'codex') {
|
|
8254
|
+
// 接了第三方时目录里全是 OpenAI 的模型名,改用这家的预设模型
|
|
8255
|
+
try {
|
|
8256
|
+
models = thirdPartyCodexModels(readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName));
|
|
8257
|
+
} catch (e) { /* 读不到 env 就走原来的目录探测 */ }
|
|
8258
|
+
}
|
|
8259
|
+
if (!models) models = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
|
|
8102
8260
|
sendJson(res, 200, {
|
|
8103
8261
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8104
8262
|
agentProgram,
|
|
@@ -8533,7 +8691,16 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8533
8691
|
|
|
8534
8692
|
const payload = await readJsonBody(req);
|
|
8535
8693
|
const removeHistory = payload.removeHistory === true;
|
|
8694
|
+
// 先记下工作目录:历史删掉后就查不到了
|
|
8695
|
+
const removedHistoryInfo = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8696
|
+
const workDirHint = (removedHistoryInfo.applied && removedHistoryInfo.applied.hostPath) ||
|
|
8697
|
+
(removedHistoryInfo.runtimeSnapshot && removedHistoryInfo.runtimeSnapshot.hostPath) || '';
|
|
8536
8698
|
const { removedContainer } = removeContainerIdempotent(ctx, sessionRef.containerName);
|
|
8699
|
+
let removedWorkDir = false;
|
|
8700
|
+
if (removeHistory) {
|
|
8701
|
+
// 连同历史一起删时,顺手清掉空的工作目录(只 rmdir,非空删不动)
|
|
8702
|
+
removedWorkDir = await webStorage.removeEmptyWorkDir({ hostPath: workDirHint, ...getStorageRootsConfig(ctx, state) }).catch(() => false);
|
|
8703
|
+
}
|
|
8537
8704
|
|
|
8538
8705
|
if (removeHistory) {
|
|
8539
8706
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
@@ -8546,6 +8713,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8546
8713
|
sendJson(res, 200, {
|
|
8547
8714
|
removed: true,
|
|
8548
8715
|
removedHistory: removeHistory,
|
|
8716
|
+
removedWorkDir,
|
|
8549
8717
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
|
|
8550
8718
|
});
|
|
8551
8719
|
}
|
|
@@ -8585,6 +8753,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8585
8753
|
}
|
|
8586
8754
|
];
|
|
8587
8755
|
|
|
8756
|
+
routes.push(...createModelServiceRoutes({
|
|
8757
|
+
ctx,
|
|
8758
|
+
state,
|
|
8759
|
+
req,
|
|
8760
|
+
res,
|
|
8761
|
+
deps: {
|
|
8762
|
+
sendJson,
|
|
8763
|
+
readJsonBody,
|
|
8764
|
+
decodeSessionName,
|
|
8765
|
+
readSnapshot: () => readWebConfigSnapshot(state.webConfigPath),
|
|
8766
|
+
writeRaw: raw => writeConfigFileSecure(path.resolve(state.webConfigPath), raw),
|
|
8767
|
+
baseRawOf,
|
|
8768
|
+
validateRaw: parseAndValidateConfigRaw,
|
|
8769
|
+
runConnectionTest: (agent, env) => runSetupConnectionTest(ctx, state, agent, env),
|
|
8770
|
+
defaultHostPath: () => getSetupWorkpath(state),
|
|
8771
|
+
listManaged: async () => {
|
|
8772
|
+
const manager = getWebNetworkManager(ctx, state);
|
|
8773
|
+
return typeof manager.listManaged === 'function' ? manager.listManaged() : [];
|
|
8774
|
+
}
|
|
8775
|
+
}
|
|
8776
|
+
}));
|
|
8777
|
+
|
|
8588
8778
|
routes.push(...createContainerManageRoutes({
|
|
8589
8779
|
ctx,
|
|
8590
8780
|
state,
|