@xcanwin/manyoyo 8.7.0 → 8.7.2
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/docker/res/claude/settings.json +3 -3
- package/lib/agent-adapters/index.js +23 -6
- package/lib/container-init.sh +45 -0
- package/lib/image-pull.js +24 -0
- package/lib/init-config.js +9 -4
- 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 +363 -0
- package/lib/provider-presets.js +58 -15
- package/lib/setup-cli.js +18 -5
- package/lib/setup-config.js +6 -1
- package/lib/setup.js +52 -5
- package/lib/web/AGENTS.md +7 -2
- package/lib/web/agent-effort.js +57 -0
- package/lib/web/agent-light-call.js +95 -0
- package/lib/web/agent-side.js +77 -0
- package/lib/web/context-usage.js +117 -0
- package/lib/web/extensions.js +158 -0
- package/lib/web/index.html +85 -83
- package/lib/web/model-services.js +248 -0
- package/lib/web/recap.js +82 -0
- package/lib/web/server.js +820 -102
- package/lib/web/session-meta.js +65 -0
- package/manyoyo.example.json +3 -1
- package/package.json +2 -2
package/lib/web/server.js
CHANGED
|
@@ -15,6 +15,12 @@ const { applyAptMirror } = require('../mirrors');
|
|
|
15
15
|
const { BUILTIN_AGENT_INSTRUCTIONS, buildAgentInstructions, composeSystemPrompt, SYSTEM_PROMPT_MAX_CHARS, toTomlBasicString } = require('../agent-instructions');
|
|
16
16
|
const containerState = require('../container-state');
|
|
17
17
|
const sessionMeta = require('./session-meta');
|
|
18
|
+
const agentEffort = require('./agent-effort');
|
|
19
|
+
const agentLight = require('./agent-light-call');
|
|
20
|
+
const webRecap = require('./recap');
|
|
21
|
+
const webExtensions = require('./extensions');
|
|
22
|
+
const agentSide = require('./agent-side');
|
|
23
|
+
const contextUsageLib = require('./context-usage');
|
|
18
24
|
const gitChanges = require('./git-changes');
|
|
19
25
|
const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
|
|
20
26
|
const { createNetworkManager, NETWORK_NAME } = require('../container-network');
|
|
@@ -27,6 +33,8 @@ const { createDeniedNotifier } = require('./denied-notify');
|
|
|
27
33
|
const { domainRuleAllows } = require('../network-policy');
|
|
28
34
|
const { createPortForwarder } = require('../port-forward');
|
|
29
35
|
const { createContainerManageRoutes, pendingRisks, STALE_PAGE_MESSAGE } = require('./container-manage');
|
|
36
|
+
const { createModelServiceRoutes } = require('./model-services');
|
|
37
|
+
const { thirdPartyCodexModels } = require('../provider-presets');
|
|
30
38
|
const { resolveManageOptions } = require('../container-manage-options');
|
|
31
39
|
const { defaultPolicy, usesProxy } = require('../network-policy');
|
|
32
40
|
const { normalizeMirrors } = require('../runtime-normalizers');
|
|
@@ -38,7 +46,7 @@ const { resolveQuickChatPath, expandTemplate } = require('../name-template');
|
|
|
38
46
|
const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
39
47
|
const { ensureImagePresent, pullImageProcess } = require('../image-pull');
|
|
40
48
|
const { waitForImport } = require('../offline-import');
|
|
41
|
-
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
|
|
49
|
+
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw, buildSetupSkippedConfigRaw } = require('../setup-config');
|
|
42
50
|
const { createImageAgentProbe } = require('../image-agents');
|
|
43
51
|
const { createAuthStore } = require('./auth-store');
|
|
44
52
|
const webUploads = require('./uploads');
|
|
@@ -231,14 +239,25 @@ function createEmptyWebAgentSession(agentId, agentName) {
|
|
|
231
239
|
engineSessionId: '',
|
|
232
240
|
usageTotal: null,
|
|
233
241
|
model: '',
|
|
242
|
+
// 推理强度:''=默认(不注入,尊重容器 env / 配置)
|
|
243
|
+
effort: '',
|
|
244
|
+
// claude 最近一轮结束时的上下文占用(见 lib/web/context-usage.js)
|
|
245
|
+
contextUsage: null,
|
|
234
246
|
queue: [],
|
|
235
247
|
queuePaused: false,
|
|
236
248
|
title: '',
|
|
249
|
+
// 自动标题(轻量模型调用生成)与是否已经试过(失败也不重试,避免每次刷新都花钱)
|
|
250
|
+
autoTitle: '',
|
|
251
|
+
autoTitleTried: false,
|
|
252
|
+
// 回顾:{ forMessageId, text, createdAt },对应哪条最终回复
|
|
253
|
+
recap: null,
|
|
237
254
|
firstPrompt: '',
|
|
238
255
|
pinned: false,
|
|
239
256
|
slashCommands: null,
|
|
240
257
|
// 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
|
|
241
|
-
rewindToUuid: ''
|
|
258
|
+
rewindToUuid: '',
|
|
259
|
+
// 分叉出来的 AGENT:下一轮 Claude 命令带 --fork-session(从原会话另开一条,拿到新会话 id 后清掉)
|
|
260
|
+
forkPending: false
|
|
242
261
|
};
|
|
243
262
|
}
|
|
244
263
|
|
|
@@ -291,13 +310,19 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
291
310
|
engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
|
|
292
311
|
usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
|
|
293
312
|
model: normalizeStoredAgentModelValue(source.model),
|
|
313
|
+
effort: agentEffort.isEffortValue(source.effort) ? source.effort : '',
|
|
314
|
+
contextUsage: contextUsageLib.normalizeStoredContextUsage(source.contextUsage),
|
|
294
315
|
queue: agentQueue.normalizeStoredQueue(source.queue),
|
|
295
316
|
queuePaused: source.queuePaused === true,
|
|
296
317
|
title: sessionMeta.normalizeTitle(source.title),
|
|
318
|
+
autoTitle: sessionMeta.normalizeTitle(source.autoTitle),
|
|
319
|
+
autoTitleTried: source.autoTitleTried === true,
|
|
320
|
+
recap: webRecap.normalizeStoredRecap(source.recap, text => agentLight.cleanLightText(text, agentLight.RECAP_MAX_CHARS)),
|
|
297
321
|
firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
|
|
298
322
|
pinned: source.pinned === true,
|
|
299
323
|
slashCommands: normalizeStoredSlashCommands(source.slashCommands),
|
|
300
|
-
rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
|
|
324
|
+
rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : '',
|
|
325
|
+
forkPending: source.forkPending === true
|
|
301
326
|
};
|
|
302
327
|
}
|
|
303
328
|
|
|
@@ -587,6 +612,26 @@ function createWebSessionMessageId() {
|
|
|
587
612
|
return `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
588
613
|
}
|
|
589
614
|
|
|
615
|
+
// 容器状态类的系统消息(已创建并启动 / 已启动 / 首次命令已执行)只在会话已经有对话时才写:
|
|
616
|
+
// 新会话一建好就写一条,聊天页就不是空的,输入框不再居中;对话中途重建或启动容器时才值得告诉用户
|
|
617
|
+
function appendWebContainerNotice(webHistoryDir, sessionRefOrContainerName, content) {
|
|
618
|
+
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
619
|
+
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
620
|
+
: sessionRefOrContainerName;
|
|
621
|
+
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
622
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
623
|
+
if (agentSession.messages.length) {
|
|
624
|
+
appendWebSessionMessage(webHistoryDir, sessionRef, 'system', content);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// 不写消息,但新建的会话照样要落盘(列表里是真的 AGENT,不是现算出来的)
|
|
628
|
+
const timestamp = new Date().toISOString();
|
|
629
|
+
if (!agentSession.createdAt) agentSession.createdAt = timestamp;
|
|
630
|
+
agentSession.updatedAt = timestamp;
|
|
631
|
+
history.updatedAt = timestamp;
|
|
632
|
+
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
633
|
+
}
|
|
634
|
+
|
|
590
635
|
function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role, content, extra = {}) {
|
|
591
636
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
592
637
|
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
@@ -760,33 +805,6 @@ function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
|
|
|
760
805
|
return removed;
|
|
761
806
|
}
|
|
762
807
|
|
|
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
|
-
|
|
790
808
|
function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
|
|
791
809
|
const history = loadWebSessionHistory(webHistoryDir, containerName);
|
|
792
810
|
history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
|
|
@@ -1000,6 +1018,11 @@ function renderAgentPromptCommand(template, prompt) {
|
|
|
1000
1018
|
return templateText.replace(/\{prompt\}/g, () => safePrompt);
|
|
1001
1019
|
}
|
|
1002
1020
|
|
|
1021
|
+
// 推理强度只接受小写字母 2~16 位(claude: low/medium/high/xhigh/max;codex 因模型而异),其余一律当没选
|
|
1022
|
+
function normalizeEffortValue(value) {
|
|
1023
|
+
return typeof value === 'string' && /^[a-z]{2,16}$/.test(value) ? value : '';
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1003
1026
|
function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
1004
1027
|
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
1005
1028
|
const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
|
|
@@ -1020,21 +1043,24 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
1020
1043
|
// 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
|
|
1021
1044
|
const images = options && Array.isArray(options.images) ? options.images : [];
|
|
1022
1045
|
const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
|
|
1046
|
+
const effort = normalizeEffortValue(options && options.effort);
|
|
1047
|
+
const effortFlag = effort && !/model_reasoning_effort/.test(suffix)
|
|
1048
|
+
? ` -c model_reasoning_effort=${effort}`
|
|
1049
|
+
: '';
|
|
1023
1050
|
const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
|
|
1024
1051
|
? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
|
|
1025
1052
|
: '';
|
|
1026
1053
|
if (sessionId) {
|
|
1027
1054
|
const promptIndex = suffix.indexOf('{prompt}');
|
|
1028
1055
|
const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
|
|
1029
|
-
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
|
|
1056
|
+
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
|
|
1030
1057
|
} else {
|
|
1031
|
-
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
|
|
1058
|
+
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${suffix}`;
|
|
1032
1059
|
}
|
|
1033
1060
|
}
|
|
1034
1061
|
// 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
: renderAgentPromptCommand(codexTemplate, prompt));
|
|
1062
|
+
// 先给模板加占位再渲染提示词:用户的提示词里写了 model_provider / --model 之类的字也不会让注入失效
|
|
1063
|
+
return renderAgentPromptCommand(withCodexBaseUrl(codexTemplate), prompt);
|
|
1038
1064
|
}
|
|
1039
1065
|
|
|
1040
1066
|
function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
@@ -1056,8 +1082,18 @@ function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
|
1056
1082
|
return `${prefix}${suffix}`;
|
|
1057
1083
|
}
|
|
1058
1084
|
|
|
1085
|
+
// 计划模式:-p 下 --permission-mode plan 和免确认参数同时出现时计划模式无效(实测),
|
|
1086
|
+
// 所以在渲染提示词之前从模板里去掉免确认参数和别的 permission-mode,再加上 plan。只处理模板,不碰提示词
|
|
1087
|
+
function applyPlanPermissionMode(templateText) {
|
|
1088
|
+
const stripped = templateText
|
|
1089
|
+
.replace(/(^|\s)--(?:allow-)?dangerously-skip-permissions(?=\s|$)/g, '$1')
|
|
1090
|
+
.replace(/(^|\s)--permission-mode(?:=\S+|\s+\S+)?(?=\s|$)/g, '$1');
|
|
1091
|
+
return stripped.replace(/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*claude)\b/, '$1 --permission-mode plan');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1059
1094
|
function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
1060
|
-
const
|
|
1095
|
+
const rawTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
1096
|
+
const templateText = options && options.permissionMode === 'plan' ? applyPlanPermissionMode(rawTemplate) : rawTemplate;
|
|
1061
1097
|
const flagSpecs = [
|
|
1062
1098
|
{ flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
|
|
1063
1099
|
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
|
|
@@ -1075,6 +1111,9 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
1075
1111
|
if (sessionId && resumeAtUuid) {
|
|
1076
1112
|
flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
|
|
1077
1113
|
}
|
|
1114
|
+
if (sessionId && options && options.forkSession === true) {
|
|
1115
|
+
flagSpecs.push({ flag: '--fork-session', pattern: /(?:^|\s)--fork-session(?:\s|$)/ });
|
|
1116
|
+
}
|
|
1078
1117
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
1079
1118
|
if (model) {
|
|
1080
1119
|
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
@@ -1095,11 +1134,15 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
1095
1134
|
if (forwardSubagentText === true) {
|
|
1096
1135
|
flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
|
|
1097
1136
|
}
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
);
|
|
1137
|
+
const claudePattern = /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/;
|
|
1138
|
+
let claudeTemplate = prependAgentFlags(templateText, claudePattern, flagSpecs);
|
|
1139
|
+
// 推理强度:CLAUDE_CODE_EFFORT_LEVEL 优先于 --effort 和 settings(实测),所以只能用命令前缀的环境变量覆盖容器里
|
|
1140
|
+
// 可能设了的值;模板自己写了就以模板为准
|
|
1141
|
+
const effort = normalizeEffortValue(options && options.effort);
|
|
1142
|
+
const claudePrefix = (templateText.match(claudePattern) || [])[2];
|
|
1143
|
+
if (effort && claudePrefix !== undefined && !/(?:^|\s)CLAUDE_CODE_EFFORT_LEVEL=/.test(claudePrefix)) {
|
|
1144
|
+
claudeTemplate = claudeTemplate.replace(/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b/, (match, envPrefix) => `${envPrefix}CLAUDE_CODE_EFFORT_LEVEL=${effort} claude`);
|
|
1145
|
+
}
|
|
1103
1146
|
const rendered = claudeTemplate === templateText
|
|
1104
1147
|
? renderAgentPromptCommand(templateText, prompt)
|
|
1105
1148
|
: renderAgentPromptCommand(claudeTemplate, prompt);
|
|
@@ -1531,10 +1574,13 @@ function normalizeAgentModelCatalogEntry(item) {
|
|
|
1531
1574
|
if (!value) {
|
|
1532
1575
|
return null;
|
|
1533
1576
|
}
|
|
1577
|
+
const efforts = agentEffort.extractEfforts(item);
|
|
1534
1578
|
return {
|
|
1535
1579
|
value,
|
|
1536
1580
|
label: pickFirstString(item && item.displayName, value),
|
|
1537
|
-
description: pickFirstString(item && item.description)
|
|
1581
|
+
description: pickFirstString(item && item.description),
|
|
1582
|
+
...(efforts.length ? { efforts } : {}),
|
|
1583
|
+
...(typeof (item && item.resolvedModel) === 'string' && item.resolvedModel ? { resolvedModel: normalizeStoredAgentModelValue(item.resolvedModel) } : {})
|
|
1538
1584
|
};
|
|
1539
1585
|
}
|
|
1540
1586
|
|
|
@@ -1551,6 +1597,17 @@ function extractClaudeModelsFromControlResponse(payload) {
|
|
|
1551
1597
|
return models.map(normalizeAgentModelCatalogEntry).filter(Boolean);
|
|
1552
1598
|
}
|
|
1553
1599
|
|
|
1600
|
+
function extractClaudeCommandsFromControlResponse(payload) {
|
|
1601
|
+
if (!payload || payload.type !== 'control_response') {
|
|
1602
|
+
return undefined;
|
|
1603
|
+
}
|
|
1604
|
+
const response = toPlainObject(payload.response);
|
|
1605
|
+
if (response.request_id !== 'manyoyo-command-catalog') {
|
|
1606
|
+
return undefined;
|
|
1607
|
+
}
|
|
1608
|
+
return sessionMeta.normalizeCommandCatalog(toPlainObject(response.response).commands);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1554
1611
|
function extractCodexModelsFromListResponse(payload) {
|
|
1555
1612
|
if (!payload || payload.id !== 'manyoyo-model-list' || !payload.result) {
|
|
1556
1613
|
return undefined;
|
|
@@ -1596,11 +1653,73 @@ async function fetchAgentModelCatalogCached(ctx, containerName, agentProgram) {
|
|
|
1596
1653
|
return pending;
|
|
1597
1654
|
}
|
|
1598
1655
|
|
|
1656
|
+
// 斜杠命令目录(claude 的 initialize 请求,不调用模型):同样按“容器”缓存 10 分钟,拿不到不缓存
|
|
1657
|
+
const commandCatalogCaches = new WeakMap();
|
|
1658
|
+
const COMMAND_CATALOG_FAILURE_TTL_MS = 15 * 1000;
|
|
1659
|
+
|
|
1660
|
+
async function fetchClaudeCommandCatalogCached(ctx, containerName) {
|
|
1661
|
+
let cache = commandCatalogCaches.get(ctx);
|
|
1662
|
+
if (!cache) {
|
|
1663
|
+
cache = new Map();
|
|
1664
|
+
commandCatalogCaches.set(ctx, cache);
|
|
1665
|
+
}
|
|
1666
|
+
const hit = cache.get(containerName);
|
|
1667
|
+
if (hit && (hit.pending || Date.now() - hit.at < (hit.commands.length ? MODEL_CATALOG_TTL_MS : COMMAND_CATALOG_FAILURE_TTL_MS))) {
|
|
1668
|
+
return hit.pending || hit.commands;
|
|
1669
|
+
}
|
|
1670
|
+
const requestLine = JSON.stringify({
|
|
1671
|
+
type: 'control_request',
|
|
1672
|
+
request_id: 'manyoyo-command-catalog',
|
|
1673
|
+
request: { subtype: 'initialize' }
|
|
1674
|
+
});
|
|
1675
|
+
const pending = runAgentProtocolProcess(
|
|
1676
|
+
ctx,
|
|
1677
|
+
containerName,
|
|
1678
|
+
'claude -p --input-format stream-json --output-format stream-json --verbose',
|
|
1679
|
+
{ stdinLines: [requestLine], onLine: extractClaudeCommandsFromControlResponse }
|
|
1680
|
+
).then(({ result }) => {
|
|
1681
|
+
const commands = Array.isArray(result) ? result : [];
|
|
1682
|
+
// 取不到(容器停了、claude 不在)也记一小会儿:输入框里每敲一个字符都会请求一次,不能每次都进容器等 8 秒
|
|
1683
|
+
cache.set(containerName, { at: Date.now(), commands });
|
|
1684
|
+
return commands;
|
|
1685
|
+
}, () => {
|
|
1686
|
+
cache.set(containerName, { at: Date.now(), commands: [] });
|
|
1687
|
+
return [];
|
|
1688
|
+
});
|
|
1689
|
+
cache.set(containerName, { pending });
|
|
1690
|
+
return pending;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// 当前模型能选的推理强度(空数组 = 确实没有,不显示;null = 目录取不到,不知道)。codex 接了第三方服务时目录里全是 OpenAI 的模型,直接当没有
|
|
1694
|
+
async function resolveEffortLevels(ctx, state, sessionRef, currentModel) {
|
|
1695
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
1696
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
1697
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
|
|
1698
|
+
history,
|
|
1699
|
+
sessionRef.agentId,
|
|
1700
|
+
containerInfo.defaultCommand
|
|
1701
|
+
));
|
|
1702
|
+
if (agentProgram !== 'claude' && agentProgram !== 'codex') {
|
|
1703
|
+
return [];
|
|
1704
|
+
}
|
|
1705
|
+
try {
|
|
1706
|
+
if (agentProgram === 'codex') {
|
|
1707
|
+
const env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
1708
|
+
if (env.OPENAI_BASE_URL) return [];
|
|
1709
|
+
}
|
|
1710
|
+
const catalog = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
|
|
1711
|
+
// 目录取不到(容器没运行、探测超时)= 不知道,和“确实不支持”要分开:不知道时不能改用户已经选好的强度
|
|
1712
|
+
return catalog.length ? agentEffort.effortLevelsFor(agentProgram, catalog, currentModel) : null;
|
|
1713
|
+
} catch (e) {
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1599
1718
|
// 以会话实际配置的 env 生成模型选项。defaultModel 是“跟随默认”实际用的模型;
|
|
1600
1719
|
// Claude 另有按档位别名(opus / sonnet / haiku …)一行一个的选项:value 是别名(--model opus 由 CLI 按 env 解析),
|
|
1601
1720
|
// label 是该档位对应的真实模型名。不同档位指向同一个模型也各占一行,不合并。
|
|
1602
1721
|
// 只读 *_MODEL 这类非敏感键,容器可写的 env 只用来展示,不参与任何安全判断
|
|
1603
|
-
const CLAUDE_TIER_ORDER = ['
|
|
1722
|
+
const CLAUDE_TIER_ORDER = ['fable', 'opus', 'sonnet', 'haiku'];
|
|
1604
1723
|
|
|
1605
1724
|
function buildEnvModelOptions(agentProgram, env) {
|
|
1606
1725
|
const source = env && typeof env === 'object' ? env : {};
|
|
@@ -2388,7 +2507,7 @@ function prepareCodexTraceEvent(payload) {
|
|
|
2388
2507
|
});
|
|
2389
2508
|
}
|
|
2390
2509
|
|
|
2391
|
-
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
|
|
2510
|
+
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = [], turnOptions = {}) {
|
|
2392
2511
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2393
2512
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
2394
2513
|
const containerMap = listWebManyoyoContainers(ctx);
|
|
@@ -2454,9 +2573,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images =
|
|
|
2454
2573
|
const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
|
|
2455
2574
|
sessionId: sessionIdForRun,
|
|
2456
2575
|
model: agentSession.model || '',
|
|
2576
|
+
effort: agentSession.effort || '',
|
|
2457
2577
|
forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
|
|
2458
2578
|
images: imagesByFlag ? images : [],
|
|
2459
2579
|
resumeAtUuid: agentSession.rewindToUuid || '',
|
|
2580
|
+
forkSession: agentSession.forkPending === true,
|
|
2581
|
+
permissionMode: turnOptions.permissionMode === 'plan' && agentMeta.agentProgram === 'claude' ? 'plan' : '',
|
|
2460
2582
|
instructions: buildAgentInstructions(
|
|
2461
2583
|
configSnapshot.parseError ? {} : configSnapshot.parsed,
|
|
2462
2584
|
runName,
|
|
@@ -2491,20 +2613,36 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
|
|
|
2491
2613
|
...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
|
|
2492
2614
|
...(turnUsage ? { usage: turnUsage } : {})
|
|
2493
2615
|
});
|
|
2616
|
+
// 分叉的第一轮:命令里带的是原会话 id,输出里的才是新会话 id
|
|
2617
|
+
const extractedSessionId = extractEngineSessionId(agentMeta.agentProgram, result.stdout);
|
|
2618
|
+
const engineSessionIdNow = agentSession.forkPending === true
|
|
2619
|
+
? extractedSessionId || meta.engineSessionId
|
|
2620
|
+
: meta.engineSessionId || extractedSessionId;
|
|
2621
|
+
// 分叉出来的 AGENT 第一轮:拿到了新的会话 id 才算分叉成功。没成功(命令失败)就保留 forkPending 和回退位置,
|
|
2622
|
+
// 下一轮仍然是分叉,不能退化成直接续接原会话(那会改写原 AGENT 的会话)
|
|
2623
|
+
// (命令在 init 之后才失败时输出里也有新 id,但新会话可能没落盘完整:这一轮必须成功才算分叉完成)
|
|
2624
|
+
const forkDone = agentSession.forkPending === true && Boolean(engineSessionIdNow) && engineSessionIdNow !== agentSession.engineSessionId
|
|
2625
|
+
&& result.exitCode === 0 && result.interrupted !== true;
|
|
2626
|
+
const keepFork = agentSession.forkPending === true && !forkDone;
|
|
2494
2627
|
const patch = {
|
|
2495
|
-
rewindToUuid: '',
|
|
2628
|
+
rewindToUuid: keepFork ? agentSession.rewindToUuid || '' : '',
|
|
2629
|
+
...(agentSession.forkPending === true && forkDone ? { forkPending: false } : {}),
|
|
2496
2630
|
lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
|
|
2497
2631
|
lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
|
|
2498
2632
|
lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
|
|
2499
2633
|
};
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
patch.engineSessionId = engineSessionId;
|
|
2634
|
+
if (engineSessionIdNow) {
|
|
2635
|
+
patch.engineSessionId = engineSessionIdNow;
|
|
2503
2636
|
}
|
|
2504
2637
|
if (turnUsage) {
|
|
2505
2638
|
patch.usageTotal = accumulateUsageTotal(agentSession.usageTotal, turnUsage);
|
|
2506
2639
|
}
|
|
2507
2640
|
if (agentMeta.agentProgram === 'claude') {
|
|
2641
|
+
// 上下文占用:只在轮结束时写一次;这一轮拿不到数据就保留上一次的
|
|
2642
|
+
const contextUsage = contextUsageLib.extractClaudeContextUsage(result.stdoutTail || result.stdout);
|
|
2643
|
+
if (contextUsage) {
|
|
2644
|
+
patch.contextUsage = { ...contextUsage, updatedAt: new Date().toISOString() };
|
|
2645
|
+
}
|
|
2508
2646
|
const slashCommands = extractClaudeInitSlashCommands(result.stdout);
|
|
2509
2647
|
if (slashCommands) {
|
|
2510
2648
|
patch.slashCommands = slashCommands;
|
|
@@ -2631,7 +2769,7 @@ async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
|
|
|
2631
2769
|
if (!removed.length) {
|
|
2632
2770
|
return { status: 409, error: '这条提示词已经不在了' };
|
|
2633
2771
|
}
|
|
2634
|
-
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
|
|
2772
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid, contextUsage: null });
|
|
2635
2773
|
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
|
|
2636
2774
|
}
|
|
2637
2775
|
return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
|
|
@@ -2687,6 +2825,296 @@ async function refreshWebAgentTitle(ctx, state, sessionRef, agentProgram) {
|
|
|
2687
2825
|
}
|
|
2688
2826
|
}
|
|
2689
2827
|
|
|
2828
|
+
// 轻量模型调用(标题、回顾):不占容器运行锁,同一 AGENT 同一种用途同时最多一个,超时 90 秒,失败返回空串。
|
|
2829
|
+
// 高频路径,失败不写日志
|
|
2830
|
+
const lightCallsInFlight = new WeakMap();
|
|
2831
|
+
|
|
2832
|
+
async function runWebLightCall(ctx, state, sessionRef, agentProgram, kind, rule, input, maxChars) {
|
|
2833
|
+
if (agentProgram !== 'claude' && agentProgram !== 'codex') {
|
|
2834
|
+
return '';
|
|
2835
|
+
}
|
|
2836
|
+
let inFlight = lightCallsInFlight.get(ctx);
|
|
2837
|
+
if (!inFlight) {
|
|
2838
|
+
inFlight = new Set();
|
|
2839
|
+
lightCallsInFlight.set(ctx, inFlight);
|
|
2840
|
+
}
|
|
2841
|
+
const flightKey = `${sessionRef.containerName}\u0000${sessionRef.agentId}\u0000${kind}`;
|
|
2842
|
+
if (inFlight.has(flightKey)) {
|
|
2843
|
+
return '';
|
|
2844
|
+
}
|
|
2845
|
+
inFlight.add(flightKey);
|
|
2846
|
+
try {
|
|
2847
|
+
const agentSession = getWebAgentSession(
|
|
2848
|
+
loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
|
|
2849
|
+
sessionRef.agentId,
|
|
2850
|
+
{ includeArchived: true }
|
|
2851
|
+
);
|
|
2852
|
+
let env = {};
|
|
2853
|
+
try {
|
|
2854
|
+
env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
2855
|
+
} catch (e) { /* 读不到 env 就按没有处理 */ }
|
|
2856
|
+
const model = agentSession ? agentSession.model || '' : '';
|
|
2857
|
+
const run = command => runWebSideCommand(ctx, sessionRef.containerName, command, { timeoutMs: agentLight.LIGHT_CALL_TIMEOUT_MS });
|
|
2858
|
+
let text = '';
|
|
2859
|
+
if (agentProgram === 'claude') {
|
|
2860
|
+
const command = agentLight.buildClaudeLightCommand({ rule, input, model: agentLight.pickClaudeLightModel(env, model) });
|
|
2861
|
+
text = agentLight.parseClaudeLightOutput((await run(command)).stdout);
|
|
2862
|
+
} else {
|
|
2863
|
+
const prompt = agentLight.buildCodexLightPrompt(rule, input);
|
|
2864
|
+
const attempt = async withEffort => {
|
|
2865
|
+
const template = withEffort ? agentLight.CODEX_LIGHT_TEMPLATE : agentLight.CODEX_LIGHT_TEMPLATE.replace(' -c model_reasoning_effort=low', '');
|
|
2866
|
+
const { stdout } = await run(buildCodexAgentExecCommand(template, prompt, { model }));
|
|
2867
|
+
return extractAgentMessageFromStructuredOutput('codex', stdout);
|
|
2868
|
+
};
|
|
2869
|
+
// 有的模型不接受 low:去掉强度再试一次
|
|
2870
|
+
text = await attempt(true) || await attempt(false);
|
|
2871
|
+
}
|
|
2872
|
+
return agentLight.cleanLightText(text, maxChars);
|
|
2873
|
+
} catch (e) {
|
|
2874
|
+
return '';
|
|
2875
|
+
} finally {
|
|
2876
|
+
inFlight.delete(flightKey);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
// 侧问:不占容器运行锁,同一 AGENT 同时一个。命令和主任务一样套上 PID 文件包装,
|
|
2881
|
+
// 客户端断开 / 超时时进到容器里终止(只杀宿主机上的 exec 客户端,容器里的进程会继续跑)
|
|
2882
|
+
const sideQuestionsInFlight = new WeakMap();
|
|
2883
|
+
|
|
2884
|
+
function runWebSideCommand(ctx, containerName, command, { onAbortSignal, timeoutMs }) {
|
|
2885
|
+
return new Promise(resolve => {
|
|
2886
|
+
const runState = { runId: agentRunControl.createRunId(), containerName, process: null, closed: null };
|
|
2887
|
+
let stdout = '';
|
|
2888
|
+
let settled = false;
|
|
2889
|
+
let child;
|
|
2890
|
+
try {
|
|
2891
|
+
child = spawnContainerExec(
|
|
2892
|
+
ctx,
|
|
2893
|
+
containerName,
|
|
2894
|
+
{ command: ['/bin/bash', '-lc', agentRunControl.wrapAgentCommand(command, runState.runId)] },
|
|
2895
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
2896
|
+
);
|
|
2897
|
+
} catch (e) {
|
|
2898
|
+
resolve({ stdout: '', error: '启动失败' });
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
runState.process = child;
|
|
2902
|
+
let markClosed;
|
|
2903
|
+
runState.closed = new Promise(done => { markClosed = done; });
|
|
2904
|
+
let aborted = '';
|
|
2905
|
+
const stop = reason => {
|
|
2906
|
+
if (settled || aborted) return;
|
|
2907
|
+
aborted = reason;
|
|
2908
|
+
terminateWebAgentRun(ctx, runState);
|
|
2909
|
+
};
|
|
2910
|
+
const timer = setTimeout(() => stop('timeout'), timeoutMs);
|
|
2911
|
+
if (typeof onAbortSignal === 'function') onAbortSignal(() => stop('aborted'));
|
|
2912
|
+
child.stdout.setEncoding('utf8');
|
|
2913
|
+
child.stdout.on('data', chunk => { if (stdout.length < 4 * 1024 * 1024) stdout += chunk; });
|
|
2914
|
+
const finish = () => {
|
|
2915
|
+
if (settled) return;
|
|
2916
|
+
settled = true;
|
|
2917
|
+
clearTimeout(timer);
|
|
2918
|
+
markClosed();
|
|
2919
|
+
resolve({ stdout, aborted });
|
|
2920
|
+
};
|
|
2921
|
+
child.once('close', finish);
|
|
2922
|
+
child.once('error', finish);
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
async function answerWebSideQuestion(ctx, state, sessionRef, agentProgram, agentSession, question, onAbortSignal) {
|
|
2927
|
+
const options = { model: agentSession.model || '', effort: agentSession.effort || '' };
|
|
2928
|
+
let command;
|
|
2929
|
+
if (agentProgram === 'claude') {
|
|
2930
|
+
command = agentSide.buildClaudeSideCommand({ sessionId: agentSession.engineSessionId, ...options, question });
|
|
2931
|
+
} else {
|
|
2932
|
+
command = buildCodexAgentExecCommand(agentSide.buildCodexSideTemplate({ threadId: agentSession.engineSessionId, ...options }), question, {});
|
|
2933
|
+
}
|
|
2934
|
+
const { stdout, aborted } = await runWebSideCommand(ctx, sessionRef.containerName, command, {
|
|
2935
|
+
onAbortSignal,
|
|
2936
|
+
timeoutMs: ctx.sideQuestionTimeoutMs || agentSide.SIDE_TIMEOUT_MS
|
|
2937
|
+
});
|
|
2938
|
+
if (aborted) {
|
|
2939
|
+
return { error: aborted === 'timeout' ? '侧问超时,已停止' : '已取消' };
|
|
2940
|
+
}
|
|
2941
|
+
const answer = agentProgram === 'claude'
|
|
2942
|
+
? agentLight.parseClaudeLightOutput(stdout)
|
|
2943
|
+
: extractAgentMessageFromStructuredOutput('codex', stdout);
|
|
2944
|
+
return answer && answer.trim() ? { answer: agentSide.clipAnswer(answer) } : { error: 'Agent 没有给出回答' };
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
const EXTENSION_AGENTS = [
|
|
2948
|
+
{ program: 'claude', label: 'Claude Code' },
|
|
2949
|
+
{ program: 'codex', label: 'Codex CLI' }
|
|
2950
|
+
];
|
|
2951
|
+
|
|
2952
|
+
async function readWebExtensions(ctx, containerName) {
|
|
2953
|
+
const probe = async command => (await runAgentProtocolProcess(ctx, containerName, command, { timeoutMs: 10000 })).stdout;
|
|
2954
|
+
const present = await Promise.all(EXTENSION_AGENTS.map(async agent => (await probe(`command -v ${agent.program}`)).trim() !== ''));
|
|
2955
|
+
return Promise.all(EXTENSION_AGENTS.filter((agent, index) => present[index]).map(async agent => {
|
|
2956
|
+
if (agent.program === 'claude') {
|
|
2957
|
+
const [catalog, mcpOutput] = await Promise.all([
|
|
2958
|
+
fetchClaudeCommandCatalogCached(ctx, containerName),
|
|
2959
|
+
runAgentProtocolProcess(ctx, containerName, 'claude mcp list', { timeoutMs: 30000 }).then(item => item.stdout)
|
|
2960
|
+
]);
|
|
2961
|
+
return { ...agent, skills: webExtensions.classifyClaudeSkills(catalog), mcp: webExtensions.parseClaudeMcpList(mcpOutput) };
|
|
2962
|
+
}
|
|
2963
|
+
const [skillsOutput, mcpOutput] = await Promise.all([
|
|
2964
|
+
probe(webExtensions.buildCodexSkillsCommand()),
|
|
2965
|
+
probe('codex mcp list --json')
|
|
2966
|
+
]);
|
|
2967
|
+
return { ...agent, skills: webExtensions.parseCodexSkillsOutput(skillsOutput), mcp: webExtensions.parseCodexMcpJson(mcpOutput) };
|
|
2968
|
+
}));
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// 分叉出来的 AGENT 不继承的字段(运行态、统计、队列);其余在下面逐个列出要复制的
|
|
2972
|
+
function cloneMessageForFork(message) {
|
|
2973
|
+
const copy = JSON.parse(JSON.stringify(message));
|
|
2974
|
+
// 幂等 id 和执行中标记只属于原来那一轮
|
|
2975
|
+
delete copy.clientMessageId;
|
|
2976
|
+
delete copy.pending;
|
|
2977
|
+
return copy;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
async function forkWebAgentSession(ctx, state, sessionRef, messageId) {
|
|
2981
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
2982
|
+
return { status: 409, error: '任务进行中,暂时不能分叉' };
|
|
2983
|
+
}
|
|
2984
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2985
|
+
const source = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
2986
|
+
if (!source || !Array.isArray(source.messages) || source.messages.length === 0) {
|
|
2987
|
+
return { status: 404, error: '这个 AGENT 还没有对话,没法分叉' };
|
|
2988
|
+
}
|
|
2989
|
+
let copyUntil = source.messages.length;
|
|
2990
|
+
let prompt = '';
|
|
2991
|
+
let images = [];
|
|
2992
|
+
let target = null;
|
|
2993
|
+
if (messageId) {
|
|
2994
|
+
const index = source.messages.findIndex(item => item && String(item.id || '') === messageId);
|
|
2995
|
+
target = index >= 0 ? source.messages[index] : null;
|
|
2996
|
+
if (!target || target.role !== 'user' || target.mode !== 'agent') {
|
|
2997
|
+
return { status: 404, error: '没有找到这条提示词' };
|
|
2998
|
+
}
|
|
2999
|
+
if (target.pending === true) {
|
|
3000
|
+
return { status: 409, error: '这一轮还在进行中' };
|
|
3001
|
+
}
|
|
3002
|
+
copyUntil = index;
|
|
3003
|
+
prompt = target.content;
|
|
3004
|
+
images = Array.isArray(target.images) ? target.images : [];
|
|
3005
|
+
} else if (source.messages.some(item => item && item.pending === true)) {
|
|
3006
|
+
return { status: 409, error: '任务进行中,暂时不能分叉' };
|
|
3007
|
+
}
|
|
3008
|
+
const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
|
|
3009
|
+
history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
|
|
3010
|
+
)).agentProgram;
|
|
3011
|
+
// 引擎会话:claude 用 --fork-session(带目标那一轮之前的位置);位置找不到或其它 Agent 就退回历史注入(不精确但不丢数据)
|
|
3012
|
+
let engineSessionId = '';
|
|
3013
|
+
let rewindToUuid = '';
|
|
3014
|
+
let conversationMode = 'history';
|
|
3015
|
+
if (program === 'claude' && source.engineSessionId) {
|
|
3016
|
+
try {
|
|
3017
|
+
const command = webRewind.buildClaudeRecordsCommand(source.engineSessionId);
|
|
3018
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
3019
|
+
const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
|
|
3020
|
+
if (!target) {
|
|
3021
|
+
// 从最新处分叉也要固定分叉点:否则原 AGENT 之后又聊了几轮,新 AGENT 第一轮会 fork 到原会话的最新状态
|
|
3022
|
+
const lastAssistant = [...records].reverse().find(item => item.t === 'assistant');
|
|
3023
|
+
if (lastAssistant && webRewind.UUID_PATTERN.test(lastAssistant.u)) {
|
|
3024
|
+
engineSessionId = source.engineSessionId;
|
|
3025
|
+
rewindToUuid = lastAssistant.u;
|
|
3026
|
+
conversationMode = 'precise';
|
|
3027
|
+
}
|
|
3028
|
+
} else {
|
|
3029
|
+
const pick = webRewind.pickClaudeResumeAt(records, { text: target.content, timeMs: Date.parse(target.timestamp) });
|
|
3030
|
+
if (pick.found && pick.uuid) {
|
|
3031
|
+
engineSessionId = source.engineSessionId;
|
|
3032
|
+
rewindToUuid = pick.uuid;
|
|
3033
|
+
conversationMode = 'precise';
|
|
3034
|
+
} else if (pick.found) {
|
|
3035
|
+
// 分叉点在第一轮之前:什么都不需要记得,开新会话
|
|
3036
|
+
conversationMode = 'precise';
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
} catch (e) {
|
|
3040
|
+
// 容器起不来 / 读不到记录:退回历史注入(不精确但不丢数据)
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
// 上面有 await:重新读一次,期间可能有人开始了新一轮
|
|
3044
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
3045
|
+
return { status: 409, error: '任务进行中,暂时不能分叉' };
|
|
3046
|
+
}
|
|
3047
|
+
const freshHistory = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
3048
|
+
const freshSource = getWebAgentSession(freshHistory, sessionRef.agentId, { includeArchived: true });
|
|
3049
|
+
if (!freshSource || freshSource.messages.length < copyUntil) {
|
|
3050
|
+
return { status: 409, error: '对话刚刚发生了变化,请重试' };
|
|
3051
|
+
}
|
|
3052
|
+
const forked = createWebAgentSession(freshHistory);
|
|
3053
|
+
forked.messages = freshSource.messages.slice(0, copyUntil).map(cloneMessageForFork);
|
|
3054
|
+
forked.agentPromptCommand = freshSource.agentPromptCommand || '';
|
|
3055
|
+
forked.model = freshSource.model || '';
|
|
3056
|
+
forked.effort = freshSource.effort || '';
|
|
3057
|
+
forked.firstPrompt = freshSource.firstPrompt || '';
|
|
3058
|
+
forked.slashCommands = freshSource.slashCommands || null;
|
|
3059
|
+
forked.engineSessionId = engineSessionId;
|
|
3060
|
+
forked.rewindToUuid = rewindToUuid;
|
|
3061
|
+
forked.forkPending = Boolean(engineSessionId);
|
|
3062
|
+
// 标题:沿用原来的显示名加“(分叉)”,不再花钱重新生成
|
|
3063
|
+
const baseTitle = freshSource.remark || freshSource.title || freshSource.autoTitle || freshSource.firstPrompt || '';
|
|
3064
|
+
forked.autoTitle = baseTitle ? sessionMeta.normalizeTitle(`${Array.from(baseTitle).slice(0, 24).join('')}(分叉)`) : '';
|
|
3065
|
+
forked.autoTitleTried = true;
|
|
3066
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, freshHistory);
|
|
3067
|
+
return {
|
|
3068
|
+
result: {
|
|
3069
|
+
name: buildWebSessionKey(sessionRef.containerName, forked.agentId),
|
|
3070
|
+
agentId: forked.agentId,
|
|
3071
|
+
prompt,
|
|
3072
|
+
images,
|
|
3073
|
+
conversationMode
|
|
3074
|
+
}
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// 第一条 Agent 消息发出后与第一轮并行生成标题(像 CLI 那样);写回前重新读一次,AGENT 被删了就放弃
|
|
3079
|
+
async function maybeStartAutoTitle(ctx, state, sessionRef, agentProgram, prompt) {
|
|
3080
|
+
try {
|
|
3081
|
+
await startAutoTitle(ctx, state, sessionRef, agentProgram, prompt);
|
|
3082
|
+
} catch (e) {
|
|
3083
|
+
// 调用方是 void:这里抛出去就是 unhandled rejection,会让 serve 退出。标题是锦上添花,失败静默
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
async function startAutoTitle(ctx, state, sessionRef, agentProgram, prompt) {
|
|
3088
|
+
if (ctx.autoTitle === false || (agentProgram !== 'claude' && agentProgram !== 'codex')) {
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
const agentSession = getWebAgentSession(
|
|
3092
|
+
loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
|
|
3093
|
+
sessionRef.agentId,
|
|
3094
|
+
{ includeArchived: true }
|
|
3095
|
+
);
|
|
3096
|
+
if (!agentSession || agentSession.autoTitleTried || agentSession.autoTitle || agentSession.title || agentSession.remark) {
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
const userMessages = agentSession.messages.filter(item => item && item.role === 'user' && item.mode === 'agent');
|
|
3100
|
+
if (userMessages.length !== 1) {
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { autoTitleTried: true });
|
|
3104
|
+
const title = await runWebLightCall(ctx, state, sessionRef, agentProgram, 'title', agentLight.TITLE_RULE, agentLight.buildTitleInput(prompt), agentLight.TITLE_MAX_CHARS);
|
|
3105
|
+
if (!title) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
const fresh = getWebAgentSession(
|
|
3109
|
+
loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
|
|
3110
|
+
sessionRef.agentId,
|
|
3111
|
+
{ includeArchived: true }
|
|
3112
|
+
);
|
|
3113
|
+
if (fresh && !fresh.autoTitle) {
|
|
3114
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { autoTitle: title });
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
2690
3118
|
function appendWebAgentTraceMessage(webHistoryDir, sessionRefOrContainerName, content, extra = {}) {
|
|
2691
3119
|
const text = String(content || '').trim();
|
|
2692
3120
|
if (!text) {
|
|
@@ -4232,20 +4660,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4232
4660
|
mirrors,
|
|
4233
4661
|
warn: message => console.warn(message)
|
|
4234
4662
|
});
|
|
4235
|
-
|
|
4236
|
-
state.webHistoryDir,
|
|
4237
|
-
messageSessionRef || runtime.containerName,
|
|
4238
|
-
'system',
|
|
4239
|
-
`容器 ${runtime.containerName} 已创建并启动。`
|
|
4240
|
-
);
|
|
4663
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已创建并启动。`);
|
|
4241
4664
|
if (runtime.first) {
|
|
4242
4665
|
const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
|
|
4243
|
-
|
|
4244
|
-
state.webHistoryDir,
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4666
|
+
if (firstResult.exitCode === 0) {
|
|
4667
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, '首次命令已执行');
|
|
4668
|
+
} else {
|
|
4669
|
+
// 失败一律写:用户必须看到
|
|
4670
|
+
appendWebSessionMessage(state.webHistoryDir, messageSessionRef || runtime.containerName, 'system', formatWebFirstFailure(firstResult, runtime.first));
|
|
4671
|
+
}
|
|
4249
4672
|
}
|
|
4250
4673
|
return;
|
|
4251
4674
|
}
|
|
@@ -4253,12 +4676,7 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4253
4676
|
const status = ctx.getContainerStatus(runtime.containerName);
|
|
4254
4677
|
if (status !== 'running') {
|
|
4255
4678
|
ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
|
|
4256
|
-
|
|
4257
|
-
state.webHistoryDir,
|
|
4258
|
-
messageSessionRef || runtime.containerName,
|
|
4259
|
-
'system',
|
|
4260
|
-
`容器 ${runtime.containerName} 已启动。`
|
|
4261
|
-
);
|
|
4679
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已启动。`);
|
|
4262
4680
|
}
|
|
4263
4681
|
await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
|
|
4264
4682
|
}
|
|
@@ -4789,6 +5207,17 @@ try {
|
|
|
4789
5207
|
`);
|
|
4790
5208
|
}
|
|
4791
5209
|
|
|
5210
|
+
// 开头事件里就带的引擎会话 id:claude 是 system/init 的 session_id,codex 是 thread.started 的 thread_id
|
|
5211
|
+
function extractEarlyEngineSessionId(agentProgram, payload) {
|
|
5212
|
+
if (!payload || typeof payload !== 'object') {
|
|
5213
|
+
return '';
|
|
5214
|
+
}
|
|
5215
|
+
const id = agentProgram === 'claude' && payload.type === 'system' && payload.subtype === 'init'
|
|
5216
|
+
? payload.session_id
|
|
5217
|
+
: (agentProgram === 'codex' && payload.type === 'thread.started' ? payload.thread_id : '');
|
|
5218
|
+
return typeof id === 'string' && webRewind.UUID_PATTERN.test(id) ? id : '';
|
|
5219
|
+
}
|
|
5220
|
+
|
|
4792
5221
|
async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
|
|
4793
5222
|
const opts = options && typeof options === 'object' ? options : {};
|
|
4794
5223
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
@@ -4864,6 +5293,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4864
5293
|
if (agentProgram === 'claude' || agentProgram === 'gemini' || agentProgram === 'codex' || agentProgram === 'opencode') {
|
|
4865
5294
|
const payload = parseJsonObjectLine(rawLine);
|
|
4866
5295
|
if (payload) {
|
|
5296
|
+
// claude 开头的 init 事件就带会话 id:第一轮没结束就让侧问等功能拿到它
|
|
5297
|
+
if (typeof opts.onEngineSessionId === 'function') {
|
|
5298
|
+
const earlySessionId = extractEarlyEngineSessionId(agentProgram, payload);
|
|
5299
|
+
if (earlySessionId) {
|
|
5300
|
+
try { opts.onEngineSessionId(earlySessionId); } catch (e) { /* 记不下来不影响本轮 */ }
|
|
5301
|
+
}
|
|
5302
|
+
}
|
|
4867
5303
|
const traceEvents = prepareStructuredTraceEvents(agentProgram, payload, structuredTraceState);
|
|
4868
5304
|
traceEvents.forEach(traceEvent => {
|
|
4869
5305
|
if (!traceEvent || !traceEvent.text) {
|
|
@@ -5556,7 +5992,7 @@ async function reconcileOrphanTurns(ctx, state) {
|
|
|
5556
5992
|
// 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
|
|
5557
5993
|
// 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
|
|
5558
5994
|
// 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
|
|
5559
|
-
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
|
|
5995
|
+
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = [], turnOptions = {}) {
|
|
5560
5996
|
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
5561
5997
|
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
5562
5998
|
return { busy: true };
|
|
@@ -5570,6 +6006,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5570
6006
|
userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
5571
6007
|
mode: 'agent',
|
|
5572
6008
|
pending: true,
|
|
6009
|
+
...(turnOptions.permissionMode === 'plan' ? { planMode: true } : {}),
|
|
5573
6010
|
...(images.length ? { images } : {}),
|
|
5574
6011
|
...(clientMessageId ? { clientMessageId: clientMessageId } : {})
|
|
5575
6012
|
});
|
|
@@ -5590,7 +6027,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5590
6027
|
|
|
5591
6028
|
let prepared = null;
|
|
5592
6029
|
try {
|
|
5593
|
-
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
|
|
6030
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images, turnOptions);
|
|
5594
6031
|
} catch (e) {
|
|
5595
6032
|
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
5596
6033
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
@@ -5599,6 +6036,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5599
6036
|
}
|
|
5600
6037
|
|
|
5601
6038
|
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
|
|
6039
|
+
void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
|
|
5602
6040
|
const traceLines = ['[执行过程]'];
|
|
5603
6041
|
const traceEvents = [];
|
|
5604
6042
|
let streamingReplyMessageId = '';
|
|
@@ -5775,6 +6213,10 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5775
6213
|
agentProgram: agentMeta.agentProgram,
|
|
5776
6214
|
// 复用路由入口占好的那把锁,而不是另起一份运行登记
|
|
5777
6215
|
runState,
|
|
6216
|
+
// 只在这个 AGENT 还没有引擎会话时提前记下(分叉 / 续接的轮次已经有了,不能被改写)
|
|
6217
|
+
onEngineSessionId: (agentMeta.agentProgram === 'claude' || agentMeta.agentProgram === 'codex') && !agentSession.engineSessionId
|
|
6218
|
+
? sessionId => patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId: sessionId })
|
|
6219
|
+
: undefined,
|
|
5778
6220
|
onEvent: handleStreamEvent
|
|
5779
6221
|
});
|
|
5780
6222
|
} finally {
|
|
@@ -5893,7 +6335,10 @@ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
|
|
|
5893
6335
|
}
|
|
5894
6336
|
|
|
5895
6337
|
const SETUP_TEST_TIMEOUT_SECONDS = 60;
|
|
5896
|
-
|
|
6338
|
+
// 要求真的调一次工具:只回一句 OK 发现不了“能连通但调不了工具”(智谱 + Claude Code 工具搜索就是这样)。
|
|
6339
|
+
// 命令文本里没有 42,算出来的结果只会来自真的执行
|
|
6340
|
+
const SETUP_TEST_PROMPT = 'Run the shell command `echo mp-$((6*7))` and reply with its exact output only.';
|
|
6341
|
+
const SETUP_TEST_EXPECT_OUTPUT = 'mp-42';
|
|
5897
6342
|
|
|
5898
6343
|
function getSetupWorkpath(state) {
|
|
5899
6344
|
return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
|
|
@@ -5980,7 +6425,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
5980
6425
|
if (!template) {
|
|
5981
6426
|
return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
|
|
5982
6427
|
}
|
|
5983
|
-
const agentCommand = withCodexBaseUrl(
|
|
6428
|
+
const agentCommand = renderAgentPromptCommand(withCodexBaseUrl(template), SETUP_TEST_PROMPT);
|
|
5984
6429
|
const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
|
|
5985
6430
|
|
|
5986
6431
|
const workpath = getSetupWorkpath(state);
|
|
@@ -6018,7 +6463,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
6018
6463
|
})
|
|
6019
6464
|
]);
|
|
6020
6465
|
clearTimeout(timer);
|
|
6021
|
-
return classifyConnectionResult(result, secrets);
|
|
6466
|
+
return classifyConnectionResult(result, secrets, { expectOutput: SETUP_TEST_EXPECT_OUTPUT });
|
|
6022
6467
|
} catch (e) {
|
|
6023
6468
|
return classifyConnectionResult({ exitCode: 1, output: e && e.message ? e.message : '' }, secrets);
|
|
6024
6469
|
} finally {
|
|
@@ -6076,8 +6521,10 @@ async function buildSetupStatus(ctx, state) {
|
|
|
6076
6521
|
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
6077
6522
|
const config = snapshot.parseError ? {} : snapshot.parsed;
|
|
6078
6523
|
const configuredAgents = getConfiguredAgents(config);
|
|
6524
|
+
const setupSkipped = Boolean(config.serve && typeof config.serve === 'object' && config.serve.setupSkipped === true);
|
|
6079
6525
|
return {
|
|
6080
|
-
needsSetup: configuredAgents.length === 0,
|
|
6526
|
+
needsSetup: configuredAgents.length === 0 && !setupSkipped,
|
|
6527
|
+
setupSkipped,
|
|
6081
6528
|
configuredAgents,
|
|
6082
6529
|
serverUser: ctx.authUser,
|
|
6083
6530
|
// 自动生成(用户看不到)的密码不算“已设置”
|
|
@@ -6108,12 +6555,13 @@ function resolveSetupRunInput(ctx, state, payload) {
|
|
|
6108
6555
|
if (!hostPath || !path.isAbsolute(hostPath)) {
|
|
6109
6556
|
throw new Error('hostPath 必须是绝对路径');
|
|
6110
6557
|
}
|
|
6111
|
-
if (hostPath === getSetupWorkpath(state)) fs.mkdirSync(hostPath, { recursive: true });
|
|
6112
6558
|
ctx.validateHostPath(hostPath);
|
|
6113
6559
|
}
|
|
6560
|
+
// 选的就是默认工作目录根:不写进运行配置(新建容器各自建 <根>/<时间> 子目录,见 buildAgentRunProfile)
|
|
6561
|
+
if (hostPath === getSetupWorkpath(state)) hostPath = '';
|
|
6562
|
+
fs.mkdirSync(getSetupWorkpath(state), { recursive: true });
|
|
6114
6563
|
const profile = buildAgentRunProfile(agent, body.env === undefined ? {} : body.env, {
|
|
6115
6564
|
existingRun,
|
|
6116
|
-
defaultHostPath: getSetupWorkpath(state),
|
|
6117
6565
|
hostPath
|
|
6118
6566
|
});
|
|
6119
6567
|
return { agent, snapshot, profile };
|
|
@@ -6196,6 +6644,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
|
|
|
6196
6644
|
agentName: agentSession.agentName,
|
|
6197
6645
|
agentRemark: agentSession.remark || '',
|
|
6198
6646
|
agentTitle: agentSession.title || '',
|
|
6647
|
+
autoTitle: agentSession.autoTitle || '',
|
|
6648
|
+
recap: agentSession.recap || null,
|
|
6199
6649
|
firstPrompt: agentSession.firstPrompt || '',
|
|
6200
6650
|
pinned: agentSession.pinned === true,
|
|
6201
6651
|
containerRemark: history.remark || '',
|
|
@@ -6208,6 +6658,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
|
|
|
6208
6658
|
agentProgram: effectiveAgentProgram || '',
|
|
6209
6659
|
resumeSupported: effectiveResumeSupported,
|
|
6210
6660
|
model: agentSession.model || '',
|
|
6661
|
+
effort: agentSession.effort || '',
|
|
6662
|
+
contextUsage: agentSession.contextUsage || null,
|
|
6211
6663
|
hostPath: applied.hostPath || '',
|
|
6212
6664
|
containerPath: applied.containerPath || '',
|
|
6213
6665
|
archived: agentSession.archived === true,
|
|
@@ -7169,6 +7621,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7169
7621
|
sendJson(res, 200, { saved: true, agent: input.agent, run: sanitizeSensitiveData(input.profile) });
|
|
7170
7622
|
}
|
|
7171
7623
|
},
|
|
7624
|
+
{
|
|
7625
|
+
method: 'POST',
|
|
7626
|
+
match: currentPath => currentPath === '/api/setup/skip' ? [] : null,
|
|
7627
|
+
handler: async () => {
|
|
7628
|
+
await readJsonBody(req);
|
|
7629
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
7630
|
+
if (snapshot.parseError) {
|
|
7631
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
7632
|
+
return;
|
|
7633
|
+
}
|
|
7634
|
+
let nextRaw;
|
|
7635
|
+
try {
|
|
7636
|
+
nextRaw = buildSetupSkippedConfigRaw(baseRawOf(snapshot));
|
|
7637
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
7638
|
+
} catch (e) {
|
|
7639
|
+
sendJson(res, 400, { error: '配置写入失败', detail: e.message || '' });
|
|
7640
|
+
return;
|
|
7641
|
+
}
|
|
7642
|
+
writeConfigFileSecure(path.resolve(state.webConfigPath), nextRaw);
|
|
7643
|
+
sendJson(res, 200, { saved: true });
|
|
7644
|
+
}
|
|
7645
|
+
},
|
|
7172
7646
|
{
|
|
7173
7647
|
method: 'POST',
|
|
7174
7648
|
match: currentPath => currentPath === '/api/setup/password' ? [] : null,
|
|
@@ -8158,27 +8632,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8158
8632
|
sendJson(res, 200, outcome);
|
|
8159
8633
|
}
|
|
8160
8634
|
},
|
|
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
|
-
},
|
|
8182
8635
|
{
|
|
8183
8636
|
// “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
|
|
8184
8637
|
method: 'GET',
|
|
@@ -8235,9 +8688,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8235
8688
|
}
|
|
8236
8689
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8237
8690
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
8691
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
8692
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
|
|
8693
|
+
history,
|
|
8694
|
+
sessionRef.agentId,
|
|
8695
|
+
containerInfo.defaultCommand
|
|
8696
|
+
));
|
|
8697
|
+
// 只有 claude 有可透传的斜杠命令;新 AGENT 没跑过一轮也能靠 initialize 目录列出来
|
|
8698
|
+
const catalog = agentProgram === 'claude'
|
|
8699
|
+
? await fetchClaudeCommandCatalogCached(ctx, sessionRef.containerName)
|
|
8700
|
+
: [];
|
|
8238
8701
|
sendJson(res, 200, {
|
|
8239
8702
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8240
|
-
|
|
8703
|
+
agentProgram,
|
|
8704
|
+
commands: agentProgram === 'claude'
|
|
8705
|
+
? sessionMeta.listSlashCommandEntries(agentSession && agentSession.slashCommands, catalog)
|
|
8706
|
+
: []
|
|
8241
8707
|
});
|
|
8242
8708
|
}
|
|
8243
8709
|
},
|
|
@@ -8258,11 +8724,19 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8258
8724
|
containerInfo.defaultCommand
|
|
8259
8725
|
);
|
|
8260
8726
|
const agentProgram = resolveAgentProgram(effectiveTemplate);
|
|
8261
|
-
|
|
8727
|
+
let models = null;
|
|
8728
|
+
if (agentProgram === 'codex') {
|
|
8729
|
+
// 接了第三方时目录里全是 OpenAI 的模型名,改用这家的预设模型
|
|
8730
|
+
try {
|
|
8731
|
+
models = thirdPartyCodexModels(readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName));
|
|
8732
|
+
} catch (e) { /* 读不到 env 就走原来的目录探测 */ }
|
|
8733
|
+
}
|
|
8734
|
+
if (!models) models = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
|
|
8262
8735
|
sendJson(res, 200, {
|
|
8263
8736
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8264
8737
|
agentProgram,
|
|
8265
|
-
|
|
8738
|
+
// 强度列表等内部字段只给服务端用(推理强度选项),不进这个接口的返回
|
|
8739
|
+
models: models.map(({ value, label, description }) => ({ value, label, description }))
|
|
8266
8740
|
});
|
|
8267
8741
|
}
|
|
8268
8742
|
},
|
|
@@ -8310,8 +8784,216 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8310
8784
|
sendJson(res, 400, { error: e.message || String(e) });
|
|
8311
8785
|
return;
|
|
8312
8786
|
}
|
|
8313
|
-
|
|
8314
|
-
|
|
8787
|
+
const patch = { model };
|
|
8788
|
+
// 换模型后,新模型不支持当前强度就回到默认(下次发送不再注入)
|
|
8789
|
+
const before = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8790
|
+
if (before && before.effort) {
|
|
8791
|
+
const levels = await resolveEffortLevels(ctx, state, sessionRef, model);
|
|
8792
|
+
if (levels && !levels.includes(before.effort)) patch.effort = '';
|
|
8793
|
+
}
|
|
8794
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, patch);
|
|
8795
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), model, effort: patch.effort !== undefined ? patch.effort : (before ? before.effort || '' : '') });
|
|
8796
|
+
}
|
|
8797
|
+
},
|
|
8798
|
+
{
|
|
8799
|
+
// 技能与 MCP(只读):容器里装了哪个 Agent 就列哪个。claude 的 skill 来自命令目录,MCP 来自 mcp list(会真实连接,最长 30 秒)
|
|
8800
|
+
method: 'GET',
|
|
8801
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/extensions$/),
|
|
8802
|
+
handler: async match => {
|
|
8803
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8804
|
+
if (!sessionRef) {
|
|
8805
|
+
return;
|
|
8806
|
+
}
|
|
8807
|
+
try {
|
|
8808
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
8809
|
+
} catch (e) {
|
|
8810
|
+
sendJson(res, 400, { error: e && e.message ? e.message : '容器没有运行' });
|
|
8811
|
+
return;
|
|
8812
|
+
}
|
|
8813
|
+
const agents = await readWebExtensions(ctx, sessionRef.containerName);
|
|
8814
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), agents });
|
|
8815
|
+
}
|
|
8816
|
+
},
|
|
8817
|
+
{
|
|
8818
|
+
// 分叉:同容器新建一个 AGENT,带着目标那一轮之前的对话(不给 messageId 就是全部)。文件不复制(同一个工作目录)
|
|
8819
|
+
method: 'POST',
|
|
8820
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/fork$/),
|
|
8821
|
+
handler: async match => {
|
|
8822
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8823
|
+
if (!sessionRef) {
|
|
8824
|
+
return;
|
|
8825
|
+
}
|
|
8826
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8827
|
+
const messageId = payload.messageId === undefined || payload.messageId === null ? '' : String(payload.messageId);
|
|
8828
|
+
const outcome = await forkWebAgentSession(ctx, state, sessionRef, messageId);
|
|
8829
|
+
if (outcome.error) {
|
|
8830
|
+
sendJson(res, outcome.status || 400, { error: outcome.error });
|
|
8831
|
+
return;
|
|
8832
|
+
}
|
|
8833
|
+
sendJson(res, 200, outcome.result);
|
|
8834
|
+
}
|
|
8835
|
+
},
|
|
8836
|
+
{
|
|
8837
|
+
// 侧问:带着上下文问一句,不占运行锁(主任务可以同时在跑)、不写历史、不写控制事件
|
|
8838
|
+
method: 'POST',
|
|
8839
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/side$/),
|
|
8840
|
+
handler: async match => {
|
|
8841
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8842
|
+
if (!sessionRef) {
|
|
8843
|
+
return;
|
|
8844
|
+
}
|
|
8845
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8846
|
+
const question = agentSide.normalizeSideQuestion(payload.question);
|
|
8847
|
+
if (!question) {
|
|
8848
|
+
sendJson(res, 400, { error: '请输入要问的内容(不超过 4000 字)' });
|
|
8849
|
+
return;
|
|
8850
|
+
}
|
|
8851
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8852
|
+
// 还没发过任何消息的 AGENT 没有历史记录:按空会话处理,不要为此创建
|
|
8853
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true }) || createEmptyWebAgentSession();
|
|
8854
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
8855
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(history, sessionRef.agentId, containerInfo.defaultCommand));
|
|
8856
|
+
if (agentProgram !== 'claude' && agentProgram !== 'codex') {
|
|
8857
|
+
sendJson(res, 400, { error: '当前 Agent 不支持侧问' });
|
|
8858
|
+
return;
|
|
8859
|
+
}
|
|
8860
|
+
// 刚分叉 / 刚回退、下一轮还没发出去:引擎会话 id 还指着原来的完整会话,带上它会看到分叉点之后 / 已被回退掉的内容
|
|
8861
|
+
if (agentSession.forkPending === true || agentSession.rewindToUuid) {
|
|
8862
|
+
sendJson(res, 409, { error: '刚分叉或回退过,先正常发一条消息再用侧问' });
|
|
8863
|
+
return;
|
|
8864
|
+
}
|
|
8865
|
+
let inFlight = sideQuestionsInFlight.get(ctx);
|
|
8866
|
+
if (!inFlight) {
|
|
8867
|
+
inFlight = new Set();
|
|
8868
|
+
sideQuestionsInFlight.set(ctx, inFlight);
|
|
8869
|
+
}
|
|
8870
|
+
const flightKey = `${sessionRef.containerName}\u0000${sessionRef.agentId}`;
|
|
8871
|
+
if (inFlight.has(flightKey)) {
|
|
8872
|
+
sendJson(res, 409, { error: '上一个侧问还在回答,请稍等' });
|
|
8873
|
+
return;
|
|
8874
|
+
}
|
|
8875
|
+
inFlight.add(flightKey);
|
|
8876
|
+
// 客户端在容器还没就绪时就断开也要算:先登记,拿到终止函数后再补上
|
|
8877
|
+
let abortSide = null;
|
|
8878
|
+
let clientGone = false;
|
|
8879
|
+
res.once('close', () => {
|
|
8880
|
+
if (res.writableEnded) return;
|
|
8881
|
+
clientGone = true;
|
|
8882
|
+
if (abortSide) abortSide();
|
|
8883
|
+
});
|
|
8884
|
+
try {
|
|
8885
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
8886
|
+
if (clientGone) {
|
|
8887
|
+
return;
|
|
8888
|
+
}
|
|
8889
|
+
const outcome = await answerWebSideQuestion(ctx, state, sessionRef, agentProgram, agentSession, question, abort => {
|
|
8890
|
+
abortSide = abort;
|
|
8891
|
+
if (clientGone) abort();
|
|
8892
|
+
});
|
|
8893
|
+
if (outcome.error) {
|
|
8894
|
+
sendJson(res, 502, { error: outcome.error });
|
|
8895
|
+
} else {
|
|
8896
|
+
sendJson(res, 200, { answer: outcome.answer });
|
|
8897
|
+
}
|
|
8898
|
+
} catch (e) {
|
|
8899
|
+
sendJson(res, 500, { error: e && e.message ? e.message : '侧问失败' });
|
|
8900
|
+
} finally {
|
|
8901
|
+
inFlight.delete(flightKey);
|
|
8902
|
+
}
|
|
8903
|
+
}
|
|
8904
|
+
},
|
|
8905
|
+
{
|
|
8906
|
+
// 回顾:隔了很久回来时由前端调用(服务端再校验条件,避免列表接口里悄悄花钱);force 给 /recap 用
|
|
8907
|
+
method: 'POST',
|
|
8908
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/recap$/),
|
|
8909
|
+
handler: async match => {
|
|
8910
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8911
|
+
if (!sessionRef) {
|
|
8912
|
+
return;
|
|
8913
|
+
}
|
|
8914
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8915
|
+
const readSession = () => getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8916
|
+
const agentSession = readSession();
|
|
8917
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8918
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
8919
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(history, sessionRef.agentId, containerInfo.defaultCommand));
|
|
8920
|
+
const respond = () => {
|
|
8921
|
+
const current = readSession();
|
|
8922
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), recap: current && current.recap ? current.recap : null });
|
|
8923
|
+
};
|
|
8924
|
+
if (!agentSession || (agentProgram !== 'claude' && agentProgram !== 'codex') || state.agentRuns.has(sessionRef.containerName)) {
|
|
8925
|
+
respond();
|
|
8926
|
+
return;
|
|
8927
|
+
}
|
|
8928
|
+
const target = webRecap.shouldGenerateRecap(agentSession, ctx.nowMs(), payload.force === true);
|
|
8929
|
+
if (!target) {
|
|
8930
|
+
respond();
|
|
8931
|
+
return;
|
|
8932
|
+
}
|
|
8933
|
+
const text = await runWebLightCall(ctx, state, sessionRef, agentProgram, 'recap', agentLight.RECAP_RULE, agentLight.buildRecapInput(target.promptText, target.replyText), agentLight.RECAP_MAX_CHARS);
|
|
8934
|
+
if (text) {
|
|
8935
|
+
// 生成期间可能又发了新一轮:回复已经不是最后一条就丢掉
|
|
8936
|
+
const fresh = readSession();
|
|
8937
|
+
const latest = fresh ? webRecap.findRecapTarget(fresh.messages) : null;
|
|
8938
|
+
if (latest && latest.replyId === target.replyId) {
|
|
8939
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { recap: { forMessageId: target.replyId, text, createdAt: new Date().toISOString() } });
|
|
8940
|
+
}
|
|
8941
|
+
}
|
|
8942
|
+
respond();
|
|
8943
|
+
}
|
|
8944
|
+
},
|
|
8945
|
+
{
|
|
8946
|
+
// 推理强度选项:来自 Agent 的模型目录(claude 的 initialize、codex 的 model/list,10 分钟缓存),找不到就是空数组(界面不显示)
|
|
8947
|
+
method: 'GET',
|
|
8948
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/effort$/),
|
|
8949
|
+
handler: async match => {
|
|
8950
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8951
|
+
if (!sessionRef) {
|
|
8952
|
+
return;
|
|
8953
|
+
}
|
|
8954
|
+
const agentSession = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8955
|
+
const levels = (await resolveEffortLevels(ctx, state, sessionRef, agentSession ? agentSession.model : '')) || [];
|
|
8956
|
+
let envDefault = '';
|
|
8957
|
+
try {
|
|
8958
|
+
const env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
8959
|
+
envDefault = agentEffort.isEffortValue(env.CLAUDE_CODE_EFFORT_LEVEL) ? env.CLAUDE_CODE_EFFORT_LEVEL : '';
|
|
8960
|
+
} catch (e) { /* 读不到 env 就不提示 */ }
|
|
8961
|
+
sendJson(res, 200, {
|
|
8962
|
+
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8963
|
+
levels,
|
|
8964
|
+
effort: agentSession && levels.includes(agentSession.effort) ? agentSession.effort : '',
|
|
8965
|
+
envDefault
|
|
8966
|
+
});
|
|
8967
|
+
}
|
|
8968
|
+
},
|
|
8969
|
+
{
|
|
8970
|
+
method: 'POST',
|
|
8971
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/effort$/),
|
|
8972
|
+
handler: async match => {
|
|
8973
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8974
|
+
if (!sessionRef) {
|
|
8975
|
+
return;
|
|
8976
|
+
}
|
|
8977
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8978
|
+
const effort = payload.effort === undefined || payload.effort === null ? '' : payload.effort;
|
|
8979
|
+
if (effort !== '' && !agentEffort.isEffortValue(effort)) {
|
|
8980
|
+
sendJson(res, 400, { error: '推理强度不合法' });
|
|
8981
|
+
return;
|
|
8982
|
+
}
|
|
8983
|
+
if (effort !== '') {
|
|
8984
|
+
const agentSession = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8985
|
+
const levels = await resolveEffortLevels(ctx, state, sessionRef, agentSession ? agentSession.model : '');
|
|
8986
|
+
if (levels === null) {
|
|
8987
|
+
sendJson(res, 409, { error: '暂时取不到当前模型支持的推理强度(容器没有运行?),稍后再试' });
|
|
8988
|
+
return;
|
|
8989
|
+
}
|
|
8990
|
+
if (!levels.includes(effort)) {
|
|
8991
|
+
sendJson(res, 400, { error: '当前模型不支持这个推理强度' });
|
|
8992
|
+
return;
|
|
8993
|
+
}
|
|
8994
|
+
}
|
|
8995
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { effort });
|
|
8996
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), effort });
|
|
8315
8997
|
}
|
|
8316
8998
|
},
|
|
8317
8999
|
{
|
|
@@ -8470,6 +9152,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8470
9152
|
...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
|
|
8471
9153
|
});
|
|
8472
9154
|
releaseAgentClaim();
|
|
9155
|
+
void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
|
|
8473
9156
|
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
|
|
8474
9157
|
agentProgram: agentMeta.agentProgram
|
|
8475
9158
|
});
|
|
@@ -8520,7 +9203,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8520
9203
|
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
|
|
8521
9204
|
return;
|
|
8522
9205
|
}
|
|
8523
|
-
|
|
9206
|
+
// 计划模式只对 claude 有意义;其它取值一律当没选
|
|
9207
|
+
const turnOptions = payload.permissionMode === 'plan' ? { permissionMode: 'plan' } : {};
|
|
9208
|
+
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages, turnOptions);
|
|
8524
9209
|
if (turn.busy) {
|
|
8525
9210
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
8526
9211
|
} else if (turn.error) {
|
|
@@ -8755,6 +9440,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8755
9440
|
}
|
|
8756
9441
|
];
|
|
8757
9442
|
|
|
9443
|
+
routes.push(...createModelServiceRoutes({
|
|
9444
|
+
ctx,
|
|
9445
|
+
state,
|
|
9446
|
+
req,
|
|
9447
|
+
res,
|
|
9448
|
+
deps: {
|
|
9449
|
+
sendJson,
|
|
9450
|
+
readJsonBody,
|
|
9451
|
+
decodeSessionName,
|
|
9452
|
+
readSnapshot: () => readWebConfigSnapshot(state.webConfigPath),
|
|
9453
|
+
writeRaw: raw => writeConfigFileSecure(path.resolve(state.webConfigPath), raw),
|
|
9454
|
+
baseRawOf,
|
|
9455
|
+
validateRaw: parseAndValidateConfigRaw,
|
|
9456
|
+
runConnectionTest: (agent, env) => runSetupConnectionTest(ctx, state, agent, env),
|
|
9457
|
+
defaultHostPath: () => getSetupWorkpath(state),
|
|
9458
|
+
listManaged: async () => {
|
|
9459
|
+
const manager = getWebNetworkManager(ctx, state);
|
|
9460
|
+
return typeof manager.listManaged === 'function' ? manager.listManaged() : [];
|
|
9461
|
+
}
|
|
9462
|
+
}
|
|
9463
|
+
}));
|
|
9464
|
+
|
|
8758
9465
|
routes.push(...createContainerManageRoutes({
|
|
8759
9466
|
ctx,
|
|
8760
9467
|
state,
|
|
@@ -8841,6 +9548,8 @@ async function startWebServer(options) {
|
|
|
8841
9548
|
dockerExecArgs: options.dockerExecArgs,
|
|
8842
9549
|
dockerExecAsync: options.dockerExecAsync,
|
|
8843
9550
|
nowMs: typeof options.nowMs === 'function' ? options.nowMs : Date.now,
|
|
9551
|
+
// 自动标题(第一句话发出后的一次轻量模型调用);测试里按“最后一次 exec 就是 Agent 那一轮”断言时可关掉
|
|
9552
|
+
autoTitle: options.autoTitle !== false,
|
|
8844
9553
|
retryRuntimeHeal: options.retryRuntimeHeal,
|
|
8845
9554
|
showImagePullHint: options.showImagePullHint,
|
|
8846
9555
|
removeContainer: options.removeContainer,
|
|
@@ -8957,6 +9666,11 @@ async function startWebServer(options) {
|
|
|
8957
9666
|
sendJson(res, 409, { error: e.message, imagePreparing: true });
|
|
8958
9667
|
return;
|
|
8959
9668
|
}
|
|
9669
|
+
// 镜像不存在 / 拉不到:附上结构化信息,网页用弹窗告诉用户怎么办
|
|
9670
|
+
if (e && e.imageProblem) {
|
|
9671
|
+
sendJson(res, 409, { error: e.message, imageProblem: e.imageProblem });
|
|
9672
|
+
return;
|
|
9673
|
+
}
|
|
8960
9674
|
ctx.logger.error('http request error', {
|
|
8961
9675
|
method: req && req.method ? req.method : '',
|
|
8962
9676
|
url: redactLoginTokenInUrl(req && req.url ? req.url : ''),
|
|
@@ -9159,6 +9873,10 @@ module.exports = {
|
|
|
9159
9873
|
__testing: {
|
|
9160
9874
|
prepareStructuredTraceEvents,
|
|
9161
9875
|
buildClaudeAgentExecCommand,
|
|
9876
|
+
buildCodexAgentExecCommand,
|
|
9877
|
+
extractEarlyEngineSessionId,
|
|
9878
|
+
finalizeWebAgentExecution,
|
|
9879
|
+
patchWebAgentSessionState,
|
|
9162
9880
|
buildWebAgentExecCommand,
|
|
9163
9881
|
buildContainerFileListCommand,
|
|
9164
9882
|
extractContentDeltaFromPayload,
|