@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
|
@@ -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/recap.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 回顾(隔了很久回来,用一句话说明刚才做到哪)。只对“最后一条消息是已经完成的最终回复”的 AGENT 生成。
|
|
4
|
+
|
|
5
|
+
const RECAP_IDLE_MS = 30 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
function isFinishedReply(message) {
|
|
8
|
+
return Boolean(message)
|
|
9
|
+
&& message.role === 'assistant'
|
|
10
|
+
&& message.mode === 'agent'
|
|
11
|
+
&& message.pending !== true
|
|
12
|
+
&& message.streamTrace !== true
|
|
13
|
+
&& message.interrupted !== true
|
|
14
|
+
&& typeof message.content === 'string'
|
|
15
|
+
&& message.content.trim() !== '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 返回 { replyId, replyText, promptText, replyAtMs };最后一条不是已完成的最终回复(执行中、被中断、系统命令输出)就是 null
|
|
19
|
+
function findRecapTarget(messages) {
|
|
20
|
+
const list = Array.isArray(messages) ? messages.filter(item => item && item.streamTrace !== true) : [];
|
|
21
|
+
const last = list[list.length - 1];
|
|
22
|
+
if (!isFinishedReply(last)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
let prompt = null;
|
|
26
|
+
for (let index = list.length - 2; index >= 0; index -= 1) {
|
|
27
|
+
if (list[index].role === 'user' && list[index].mode === 'agent') {
|
|
28
|
+
prompt = list[index];
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!prompt) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const replyAtMs = Date.parse(last.timestamp);
|
|
36
|
+
return {
|
|
37
|
+
replyId: String(last.id || ''),
|
|
38
|
+
replyText: last.content,
|
|
39
|
+
promptText: typeof prompt.content === 'string' ? prompt.content : '',
|
|
40
|
+
replyAtMs: Number.isFinite(replyAtMs) ? replyAtMs : 0
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 是否要生成:没有目标、已经给这条回复生成过(除非 force)、空闲不到 30 分钟(除非 force)都不生成
|
|
45
|
+
function shouldGenerateRecap(agentSession, nowMs, force) {
|
|
46
|
+
const target = findRecapTarget(agentSession && agentSession.messages);
|
|
47
|
+
if (!target || !target.replyId) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!force) {
|
|
51
|
+
if (agentSession.recap && agentSession.recap.forMessageId === target.replyId) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (!target.replyAtMs || nowMs - target.replyAtMs < RECAP_IDLE_MS) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return target;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeStoredRecap(value, normalizeText) {
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const text = normalizeText(value.text);
|
|
66
|
+
const forMessageId = typeof value.forMessageId === 'string' ? value.forMessageId : '';
|
|
67
|
+
if (!text || !forMessageId) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
forMessageId,
|
|
72
|
+
text,
|
|
73
|
+
createdAt: typeof value.createdAt === 'string' && !Number.isNaN(Date.parse(value.createdAt)) ? value.createdAt : null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
RECAP_IDLE_MS,
|
|
79
|
+
findRecapTarget,
|
|
80
|
+
shouldGenerateRecap,
|
|
81
|
+
normalizeStoredRecap
|
|
82
|
+
};
|