@xcanwin/manyoyo 8.7.0 → 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/lib/agent-adapters/index.js +23 -6
- 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/provider-presets.js +45 -15
- 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 +97 -77
- package/manyoyo.example.json +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const containersLib = require('../model-service-containers');
|
|
5
|
+
const importLib = require('../model-service-import');
|
|
6
|
+
const {
|
|
7
|
+
listModelServices,
|
|
8
|
+
suggestReuse,
|
|
9
|
+
buildNewService,
|
|
10
|
+
buildReuseService,
|
|
11
|
+
buildServiceEdit,
|
|
12
|
+
applyConfigOps,
|
|
13
|
+
keyValueOf,
|
|
14
|
+
agentLabelOf,
|
|
15
|
+
assertOpsApplied
|
|
16
|
+
} = require('../model-services');
|
|
17
|
+
const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
18
|
+
const { providersForAgent } = require('../provider-presets');
|
|
19
|
+
|
|
20
|
+
// 「模型服务」接口:列表 / 新增(含一键复用)/ 编辑 / 删除 / 测试连接。
|
|
21
|
+
// 全部挂在全局认证网关之后,非 GET 由 handleWebApi 统一校验 X-Requested-With。
|
|
22
|
+
// 响应里只有 Key 的末 4 位;写配置都是“读当前磁盘 → 局部修改 → 写回”,中间没有 await。
|
|
23
|
+
function toObject(value) {
|
|
24
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createModelServiceRoutes({ ctx, state, req, res, deps }) {
|
|
28
|
+
const { sendJson, readJsonBody, decodeSessionName, readSnapshot, writeRaw, baseRawOf, validateRaw, runConnectionTest, defaultHostPath, listManaged } = deps;
|
|
29
|
+
const homeDir = ctx.homeDir || os.homedir();
|
|
30
|
+
|
|
31
|
+
function loadConfig() {
|
|
32
|
+
const snapshot = readSnapshot();
|
|
33
|
+
if (snapshot.parseError) {
|
|
34
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { snapshot, config: snapshot.parsed || {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 改动落到原文后必须读得回来(assertOpsApplied),否则不写盘、报错
|
|
41
|
+
function saveRaw(nextRaw, ops) {
|
|
42
|
+
validateRaw(nextRaw);
|
|
43
|
+
assertOpsApplied(nextRaw, ops);
|
|
44
|
+
writeRaw(nextRaw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveOps(snapshot, ops) {
|
|
48
|
+
saveRaw(applyConfigOps(baseRawOf(snapshot), ops), ops);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fail(e, status = 400) {
|
|
52
|
+
sendJson(res, status, { error: e && e.message ? e.message : '参数无效' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 受管容器里还在用这个服务当前 Key 的(按 box/env 里的 Key 相等判断);运行时查不到就当没有
|
|
56
|
+
async function usingContainers(config, name) {
|
|
57
|
+
const service = listModelServices(config).find(item => item.name === name);
|
|
58
|
+
if (!service) return [];
|
|
59
|
+
try {
|
|
60
|
+
return containersLib.findUsingContainers({
|
|
61
|
+
homeDir,
|
|
62
|
+
containers: await listManaged(),
|
|
63
|
+
keyEnv: service.keyEnv,
|
|
64
|
+
keyValue: keyValueOf(config, name),
|
|
65
|
+
baseUrlKey: AGENT_ENV_SCHEMA[service.agent].baseUrlKey,
|
|
66
|
+
baseUrl: service.baseUrl
|
|
67
|
+
});
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function overview(config) {
|
|
74
|
+
const quickRun = config.serve && config.serve.quickChat && typeof config.serve.quickChat.run === 'string' ? config.serve.quickChat.run : '';
|
|
75
|
+
return {
|
|
76
|
+
services: listModelServices(config).map(item => ({ ...item, quickChatDefault: item.name === quickRun })),
|
|
77
|
+
suggestions: suggestReuse(config),
|
|
78
|
+
// 向导里的服务商预设,新增 / 编辑表单据此填地址和模型
|
|
79
|
+
agents: ['claude', 'codex'].map(id => ({
|
|
80
|
+
id,
|
|
81
|
+
label: agentLabelOf(id),
|
|
82
|
+
providers: providersForAgent(id)
|
|
83
|
+
}))
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
method: 'GET',
|
|
90
|
+
match: p => (p === '/api/model-services' ? [] : null),
|
|
91
|
+
handler: async () => {
|
|
92
|
+
const loaded = loadConfig();
|
|
93
|
+
if (!loaded) return;
|
|
94
|
+
sendJson(res, 200, overview(loaded.config));
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
// 本机已有的 Key / 地址 / 模型(只读取不写入;Key 只给末 4 位)
|
|
99
|
+
method: 'GET',
|
|
100
|
+
match: p => (p === '/api/model-services/import-preview' ? [] : null),
|
|
101
|
+
handler: async () => {
|
|
102
|
+
const loaded = loadConfig();
|
|
103
|
+
if (!loaded) return;
|
|
104
|
+
const sources = importLib.IMPORT_AGENTS.map(item => ({ agent: item.id, label: agentLabelOf(item.id), where: item.sources }));
|
|
105
|
+
sendJson(res, 200, { candidates: importLib.toPreview(importLib.scanLocalServices(homeDir, loaded.config)), sources });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// 导入选中的候选:{ ids: ['claude', 'codex'] };服务端重新扫描,不信任前端传来的内容
|
|
110
|
+
method: 'POST',
|
|
111
|
+
match: p => (p === '/api/model-services/import' ? [] : null),
|
|
112
|
+
handler: async () => {
|
|
113
|
+
const body = toObject(await readJsonBody(req));
|
|
114
|
+
const loaded = loadConfig();
|
|
115
|
+
if (!loaded) return;
|
|
116
|
+
let built;
|
|
117
|
+
try {
|
|
118
|
+
const ids = Array.isArray(body.ids) ? body.ids.map(String) : [];
|
|
119
|
+
if (!ids.length) throw new Error('请选择要导入的服务');
|
|
120
|
+
built = importLib.buildImportRuns(loaded.config, importLib.scanLocalServices(homeDir, loaded.config), ids, { defaultHostPath: defaultHostPath() });
|
|
121
|
+
saveOps(loaded.snapshot, built.map(item => ({ type: 'set', path: ['runs', item.name], value: item.profile })));
|
|
122
|
+
} catch (e) {
|
|
123
|
+
fail(e);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
sendJson(res, 200, { saved: true, names: built.map(item => item.name), ...overview(readSnapshot().parsed || {}) });
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
// 新增:{ agent, providerId | baseUrl, key, model?, name? } 或一键复用 { reuseFrom, agent }
|
|
131
|
+
method: 'POST',
|
|
132
|
+
match: p => (p === '/api/model-services' ? [] : null),
|
|
133
|
+
handler: async () => {
|
|
134
|
+
const body = await readJsonBody(req);
|
|
135
|
+
const loaded = loadConfig();
|
|
136
|
+
if (!loaded) return;
|
|
137
|
+
let built;
|
|
138
|
+
try {
|
|
139
|
+
built = body && body.reuseFrom
|
|
140
|
+
? buildReuseService(loaded.config, String(body.reuseFrom), String(body.agent || ''), { defaultHostPath: defaultHostPath() })
|
|
141
|
+
: buildNewService(loaded.config, body || {}, { defaultHostPath: defaultHostPath() });
|
|
142
|
+
saveOps(loaded.snapshot, [{ type: 'set', path: ['runs', built.name], value: built.profile }]);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
fail(e);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
sendJson(res, 200, { saved: true, name: built.name, ...overview(readSnapshot().parsed || {}) });
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
// 还在用这个服务当前 Key 的容器(编辑页据此提示“N 个容器还在用旧设置”)
|
|
152
|
+
method: 'GET',
|
|
153
|
+
match: p => p.match(/^\/api\/model-services\/([^/]+)\/containers$/),
|
|
154
|
+
handler: async ([, encoded]) => {
|
|
155
|
+
const name = decodeSessionName(encoded);
|
|
156
|
+
const loaded = loadConfig();
|
|
157
|
+
if (!loaded) return;
|
|
158
|
+
sendJson(res, 200, { containers: (await usingContainers(loaded.config, name)).map(item => item.name) });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
// syncContainers:true 时,把新值一并写进还在用旧 Key 的容器(下一轮对话生效)
|
|
163
|
+
method: 'PUT',
|
|
164
|
+
match: p => p.match(/^\/api\/model-services\/([^/]+)$/),
|
|
165
|
+
handler: async ([, encoded]) => {
|
|
166
|
+
const name = decodeSessionName(encoded);
|
|
167
|
+
const body = toObject(await readJsonBody(req));
|
|
168
|
+
// 要 await 的事(查容器)放在读配置之前:读 → 改 → 写之间不能有 await,否则会盖掉别人刚写的修改
|
|
169
|
+
let targets = [];
|
|
170
|
+
if (body.syncContainers === true) {
|
|
171
|
+
const before = loadConfig();
|
|
172
|
+
if (!before) return;
|
|
173
|
+
targets = await usingContainers(before.config, name);
|
|
174
|
+
}
|
|
175
|
+
const loaded = loadConfig();
|
|
176
|
+
if (!loaded) return;
|
|
177
|
+
let updatedContainers = [];
|
|
178
|
+
try {
|
|
179
|
+
const { ops } = buildServiceEdit(loaded.config, name, body);
|
|
180
|
+
if (ops.length) saveOps(loaded.snapshot, ops);
|
|
181
|
+
if (ops.length && targets.length) {
|
|
182
|
+
const set = {};
|
|
183
|
+
ops.filter(op => op.type === 'set').forEach(op => { set[op.path[op.path.length - 1]] = op.value; });
|
|
184
|
+
const remove = ops.filter(op => op.type === 'remove').map(op => op.path[op.path.length - 1]);
|
|
185
|
+
updatedContainers = containersLib.applyToContainers({ homeDir, targets, set, remove });
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
fail(e);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
sendJson(res, 200, { saved: true, name, updatedContainers, ...overview(readSnapshot().parsed || {}) });
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
// 只删配置,不影响已有容器;是快捷对话默认时一并清掉,避免指向不存在的运行配置
|
|
196
|
+
method: 'DELETE',
|
|
197
|
+
match: p => p.match(/^\/api\/model-services\/([^/]+)$/),
|
|
198
|
+
handler: async ([, encoded]) => {
|
|
199
|
+
const name = decodeSessionName(encoded);
|
|
200
|
+
const loaded = loadConfig();
|
|
201
|
+
if (!loaded) return;
|
|
202
|
+
try {
|
|
203
|
+
if (!listModelServices(loaded.config).some(item => item.name === name)) throw new Error(`找不到模型服务: ${name}`);
|
|
204
|
+
const ops = [{ type: 'remove', path: ['runs', name] }];
|
|
205
|
+
const quick = loaded.config.serve && loaded.config.serve.quickChat;
|
|
206
|
+
if (quick && quick.run === name) ops.push({ type: 'remove', path: ['serve', 'quickChat', 'run'] });
|
|
207
|
+
saveOps(loaded.snapshot, ops);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
fail(e);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
sendJson(res, 200, { deleted: true, name, ...overview(readSnapshot().parsed || {}) });
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
// 用已保存的 Key 测试(Key 不经过浏览器)
|
|
217
|
+
method: 'POST',
|
|
218
|
+
match: p => p.match(/^\/api\/model-services\/([^/]+)\/test$/),
|
|
219
|
+
handler: async ([, encoded]) => {
|
|
220
|
+
const name = decodeSessionName(encoded);
|
|
221
|
+
await readJsonBody(req);
|
|
222
|
+
const loaded = loadConfig();
|
|
223
|
+
if (!loaded) return;
|
|
224
|
+
const service = listModelServices(loaded.config).find(item => item.name === name);
|
|
225
|
+
if (!service) {
|
|
226
|
+
sendJson(res, 404, { error: `找不到模型服务: ${name}` });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (state.setupTestRunning) {
|
|
230
|
+
sendJson(res, 409, { error: '已有连接测试正在进行,请稍后重试' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const env = {};
|
|
234
|
+
for (const [key, value] of Object.entries(loaded.config.runs[name].env || {})) {
|
|
235
|
+
if (typeof value === 'string' || typeof value === 'number') env[key] = String(value);
|
|
236
|
+
}
|
|
237
|
+
state.setupTestRunning = true;
|
|
238
|
+
try {
|
|
239
|
+
sendJson(res, 200, await runConnectionTest(service.agent, env));
|
|
240
|
+
} finally {
|
|
241
|
+
state.setupTestRunning = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { createModelServiceRoutes };
|
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');
|
|
@@ -38,7 +40,7 @@ const { resolveQuickChatPath, expandTemplate } = require('../name-template');
|
|
|
38
40
|
const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
39
41
|
const { ensureImagePresent, pullImageProcess } = require('../image-pull');
|
|
40
42
|
const { waitForImport } = require('../offline-import');
|
|
41
|
-
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
|
|
43
|
+
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw, buildSetupSkippedConfigRaw } = require('../setup-config');
|
|
42
44
|
const { createImageAgentProbe } = require('../image-agents');
|
|
43
45
|
const { createAuthStore } = require('./auth-store');
|
|
44
46
|
const webUploads = require('./uploads');
|
|
@@ -587,6 +589,26 @@ function createWebSessionMessageId() {
|
|
|
587
589
|
return `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
588
590
|
}
|
|
589
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
|
+
|
|
590
612
|
function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role, content, extra = {}) {
|
|
591
613
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
592
614
|
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
@@ -760,33 +782,6 @@ function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
|
|
|
760
782
|
return removed;
|
|
761
783
|
}
|
|
762
784
|
|
|
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
785
|
function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
|
|
791
786
|
const history = loadWebSessionHistory(webHistoryDir, containerName);
|
|
792
787
|
history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
|
|
@@ -1032,9 +1027,8 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
1032
1027
|
}
|
|
1033
1028
|
}
|
|
1034
1029
|
// 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
: renderAgentPromptCommand(codexTemplate, prompt));
|
|
1030
|
+
// 先给模板加占位再渲染提示词:用户的提示词里写了 model_provider / --model 之类的字也不会让注入失效
|
|
1031
|
+
return renderAgentPromptCommand(withCodexBaseUrl(codexTemplate), prompt);
|
|
1038
1032
|
}
|
|
1039
1033
|
|
|
1040
1034
|
function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
@@ -4232,20 +4226,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4232
4226
|
mirrors,
|
|
4233
4227
|
warn: message => console.warn(message)
|
|
4234
4228
|
});
|
|
4235
|
-
|
|
4236
|
-
state.webHistoryDir,
|
|
4237
|
-
messageSessionRef || runtime.containerName,
|
|
4238
|
-
'system',
|
|
4239
|
-
`容器 ${runtime.containerName} 已创建并启动。`
|
|
4240
|
-
);
|
|
4229
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已创建并启动。`);
|
|
4241
4230
|
if (runtime.first) {
|
|
4242
4231
|
const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
|
|
4243
|
-
|
|
4244
|
-
state.webHistoryDir,
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
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
|
+
}
|
|
4249
4238
|
}
|
|
4250
4239
|
return;
|
|
4251
4240
|
}
|
|
@@ -4253,12 +4242,7 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
4253
4242
|
const status = ctx.getContainerStatus(runtime.containerName);
|
|
4254
4243
|
if (status !== 'running') {
|
|
4255
4244
|
ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
|
|
4256
|
-
|
|
4257
|
-
state.webHistoryDir,
|
|
4258
|
-
messageSessionRef || runtime.containerName,
|
|
4259
|
-
'system',
|
|
4260
|
-
`容器 ${runtime.containerName} 已启动。`
|
|
4261
|
-
);
|
|
4245
|
+
appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已启动。`);
|
|
4262
4246
|
}
|
|
4263
4247
|
await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
|
|
4264
4248
|
}
|
|
@@ -5893,7 +5877,10 @@ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
|
|
|
5893
5877
|
}
|
|
5894
5878
|
|
|
5895
5879
|
const SETUP_TEST_TIMEOUT_SECONDS = 60;
|
|
5896
|
-
|
|
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';
|
|
5897
5884
|
|
|
5898
5885
|
function getSetupWorkpath(state) {
|
|
5899
5886
|
return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
|
|
@@ -5980,7 +5967,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
5980
5967
|
if (!template) {
|
|
5981
5968
|
return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
|
|
5982
5969
|
}
|
|
5983
|
-
const agentCommand = withCodexBaseUrl(
|
|
5970
|
+
const agentCommand = renderAgentPromptCommand(withCodexBaseUrl(template), SETUP_TEST_PROMPT);
|
|
5984
5971
|
const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
|
|
5985
5972
|
|
|
5986
5973
|
const workpath = getSetupWorkpath(state);
|
|
@@ -6018,7 +6005,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
|
6018
6005
|
})
|
|
6019
6006
|
]);
|
|
6020
6007
|
clearTimeout(timer);
|
|
6021
|
-
return classifyConnectionResult(result, secrets);
|
|
6008
|
+
return classifyConnectionResult(result, secrets, { expectOutput: SETUP_TEST_EXPECT_OUTPUT });
|
|
6022
6009
|
} catch (e) {
|
|
6023
6010
|
return classifyConnectionResult({ exitCode: 1, output: e && e.message ? e.message : '' }, secrets);
|
|
6024
6011
|
} finally {
|
|
@@ -6076,8 +6063,10 @@ async function buildSetupStatus(ctx, state) {
|
|
|
6076
6063
|
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
6077
6064
|
const config = snapshot.parseError ? {} : snapshot.parsed;
|
|
6078
6065
|
const configuredAgents = getConfiguredAgents(config);
|
|
6066
|
+
const setupSkipped = Boolean(config.serve && typeof config.serve === 'object' && config.serve.setupSkipped === true);
|
|
6079
6067
|
return {
|
|
6080
|
-
needsSetup: configuredAgents.length === 0,
|
|
6068
|
+
needsSetup: configuredAgents.length === 0 && !setupSkipped,
|
|
6069
|
+
setupSkipped,
|
|
6081
6070
|
configuredAgents,
|
|
6082
6071
|
serverUser: ctx.authUser,
|
|
6083
6072
|
// 自动生成(用户看不到)的密码不算“已设置”
|
|
@@ -6108,12 +6097,13 @@ function resolveSetupRunInput(ctx, state, payload) {
|
|
|
6108
6097
|
if (!hostPath || !path.isAbsolute(hostPath)) {
|
|
6109
6098
|
throw new Error('hostPath 必须是绝对路径');
|
|
6110
6099
|
}
|
|
6111
|
-
if (hostPath === getSetupWorkpath(state)) fs.mkdirSync(hostPath, { recursive: true });
|
|
6112
6100
|
ctx.validateHostPath(hostPath);
|
|
6113
6101
|
}
|
|
6102
|
+
// 选的就是默认工作目录根:不写进运行配置(新建容器各自建 <根>/<时间> 子目录,见 buildAgentRunProfile)
|
|
6103
|
+
if (hostPath === getSetupWorkpath(state)) hostPath = '';
|
|
6104
|
+
fs.mkdirSync(getSetupWorkpath(state), { recursive: true });
|
|
6114
6105
|
const profile = buildAgentRunProfile(agent, body.env === undefined ? {} : body.env, {
|
|
6115
6106
|
existingRun,
|
|
6116
|
-
defaultHostPath: getSetupWorkpath(state),
|
|
6117
6107
|
hostPath
|
|
6118
6108
|
});
|
|
6119
6109
|
return { agent, snapshot, profile };
|
|
@@ -7169,6 +7159,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
7169
7159
|
sendJson(res, 200, { saved: true, agent: input.agent, run: sanitizeSensitiveData(input.profile) });
|
|
7170
7160
|
}
|
|
7171
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
|
+
},
|
|
7172
7184
|
{
|
|
7173
7185
|
method: 'POST',
|
|
7174
7186
|
match: currentPath => currentPath === '/api/setup/password' ? [] : null,
|
|
@@ -8158,27 +8170,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8158
8170
|
sendJson(res, 200, outcome);
|
|
8159
8171
|
}
|
|
8160
8172
|
},
|
|
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
8173
|
{
|
|
8183
8174
|
// “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
|
|
8184
8175
|
method: 'GET',
|
|
@@ -8258,7 +8249,14 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8258
8249
|
containerInfo.defaultCommand
|
|
8259
8250
|
);
|
|
8260
8251
|
const agentProgram = resolveAgentProgram(effectiveTemplate);
|
|
8261
|
-
|
|
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);
|
|
8262
8260
|
sendJson(res, 200, {
|
|
8263
8261
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8264
8262
|
agentProgram,
|
|
@@ -8755,6 +8753,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8755
8753
|
}
|
|
8756
8754
|
];
|
|
8757
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
|
+
|
|
8758
8778
|
routes.push(...createContainerManageRoutes({
|
|
8759
8779
|
ctx,
|
|
8760
8780
|
state,
|
package/manyoyo.example.json
CHANGED
|
@@ -86,12 +86,14 @@
|
|
|
86
86
|
// 一旦设置 title,则固定显示该值(不再随会话变化),留空字符串 "" 即固定显示为空
|
|
87
87
|
// quickChat 是网页端"快捷对话"按钮的配置:path 为工作根目录(每次点击按时间戳建子目录),
|
|
88
88
|
// run 为使用哪个 runs.<name> 配置;也可以在网页「系统设置 > 快捷对话」里填写,效果一样
|
|
89
|
+
// setupSkipped 为 true 表示用户点过首次向导的“跳过”,之后不再自动弹出(网页写入,一般不用手写)
|
|
89
90
|
// "serve": {
|
|
90
91
|
// "title": "My MANYOYO",
|
|
91
92
|
// "quickChat": {
|
|
92
93
|
// "path": "~/.manyoyo/work/",
|
|
93
94
|
// "run": "claude"
|
|
94
|
-
// }
|
|
95
|
+
// },
|
|
96
|
+
// "setupSkipped": true
|
|
95
97
|
// },
|
|
96
98
|
|
|
97
99
|
// 可选插件(manyoyo playwright):容器内浏览器默认就能用,这里只放少量可调项
|