@xcanwin/manyoyo 6.2.5 → 6.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/web/frontend/app.css +111 -137
- package/lib/web/frontend/app.html +28 -1
- package/lib/web/frontend/app.js +482 -396
- package/lib/web/frontend/file-browser.js +77 -1
- package/lib/web/frontend/markdown-renderer.js +16 -4
- package/lib/web/frontend/markdown.css +21 -21
- package/lib/web/server.js +452 -22
- package/package.json +1 -1
package/lib/web/server.js
CHANGED
|
@@ -45,6 +45,16 @@ const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
|
|
|
45
45
|
const NATIVE_SESSION_RESUME_PROGRAMS = new Set(['claude', 'codex', 'opencode']);
|
|
46
46
|
const WEB_FILE_PREVIEW_MAX_BYTES = 512 * 1024;
|
|
47
47
|
const WEB_FILE_EDIT_MAX_BYTES = 2 * 1024 * 1024;
|
|
48
|
+
const WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER = 4;
|
|
49
|
+
const IMAGE_EXTENSION_CONTENT_TYPES = {
|
|
50
|
+
'.png': 'image/png',
|
|
51
|
+
'.jpg': 'image/jpeg',
|
|
52
|
+
'.jpeg': 'image/jpeg',
|
|
53
|
+
'.gif': 'image/gif',
|
|
54
|
+
'.webp': 'image/webp',
|
|
55
|
+
'.bmp': 'image/bmp',
|
|
56
|
+
'.ico': 'image/x-icon'
|
|
57
|
+
};
|
|
48
58
|
const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
|
|
49
59
|
const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
|
|
50
60
|
const WEB_SESSION_KEY_SEPARATOR = '~';
|
|
@@ -157,11 +167,21 @@ function normalizeWebAgentName(agentId, agentName) {
|
|
|
157
167
|
return String(agentId || '').trim() || WEB_DEFAULT_AGENT_NAME;
|
|
158
168
|
}
|
|
159
169
|
|
|
170
|
+
const WEB_REMARK_MAX_LENGTH = 200;
|
|
171
|
+
|
|
172
|
+
function normalizeRemarkText(value) {
|
|
173
|
+
if (typeof value !== 'string') {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
return value.trim().slice(0, WEB_REMARK_MAX_LENGTH);
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
function createEmptyWebAgentSession(agentId, agentName) {
|
|
161
180
|
return {
|
|
162
181
|
agentId,
|
|
163
182
|
agentName: normalizeWebAgentName(agentId, agentName),
|
|
164
183
|
agentPromptCommand: '',
|
|
184
|
+
remark: '',
|
|
165
185
|
createdAt: null,
|
|
166
186
|
updatedAt: null,
|
|
167
187
|
messages: [],
|
|
@@ -181,6 +201,7 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
181
201
|
agentPromptCommand: typeof source.agentPromptCommand === 'string'
|
|
182
202
|
? normalizeAgentPromptCommandTemplate(source.agentPromptCommand, `agents.${agentId}.agentPromptCommand`)
|
|
183
203
|
: '',
|
|
204
|
+
remark: normalizeRemarkText(source.remark),
|
|
184
205
|
createdAt: typeof source.createdAt === 'string' ? source.createdAt : null,
|
|
185
206
|
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : null,
|
|
186
207
|
messages: Array.isArray(source.messages) ? source.messages : [],
|
|
@@ -244,6 +265,9 @@ function normalizeWebHistoryRecord(containerName, rawData) {
|
|
|
244
265
|
const applied = data.applied && typeof data.applied === 'object' && !Array.isArray(data.applied)
|
|
245
266
|
? data.applied
|
|
246
267
|
: null;
|
|
268
|
+
const runtimeSnapshot = data.runtimeSnapshot && typeof data.runtimeSnapshot === 'object' && !Array.isArray(data.runtimeSnapshot)
|
|
269
|
+
? data.runtimeSnapshot
|
|
270
|
+
: null;
|
|
247
271
|
const history = {
|
|
248
272
|
containerName,
|
|
249
273
|
updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : null,
|
|
@@ -251,7 +275,10 @@ function normalizeWebHistoryRecord(containerName, rawData) {
|
|
|
251
275
|
typeof data.agentPromptCommand === 'string' ? data.agentPromptCommand : '',
|
|
252
276
|
applied
|
|
253
277
|
),
|
|
278
|
+
remark: normalizeRemarkText(data.remark),
|
|
254
279
|
applied,
|
|
280
|
+
// 服务端内部字段,绝不能被 buildSessionSummary/buildSessionDetail/buildSessionAudit 引用或透传给前端。
|
|
281
|
+
runtimeSnapshot,
|
|
255
282
|
agents: {}
|
|
256
283
|
};
|
|
257
284
|
|
|
@@ -2547,6 +2574,24 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
2547
2574
|
containerPorts,
|
|
2548
2575
|
agentPromptCommand,
|
|
2549
2576
|
defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
|
|
2577
|
+
// 仅供服务端内部复用(如"创建相同配置容器"/"复制容器"),绝不能透传给任何对外响应字段,
|
|
2578
|
+
// 因为 env/volumes/ports 可能包含密钥原始值(对外响应向来只暴露 envCount 等计数)。
|
|
2579
|
+
runtimeSnapshot: {
|
|
2580
|
+
hostPath,
|
|
2581
|
+
containerPath,
|
|
2582
|
+
imageName,
|
|
2583
|
+
imageVersion,
|
|
2584
|
+
containerMode,
|
|
2585
|
+
shellPrefix: shellPrefix || '',
|
|
2586
|
+
shell: shell || '',
|
|
2587
|
+
shellSuffix: shellSuffix || '',
|
|
2588
|
+
agentPromptCommand: configuredAgentPromptCommand,
|
|
2589
|
+
yolo: yolo || '',
|
|
2590
|
+
env: resolvedBase.env || {},
|
|
2591
|
+
envFile: resolvedBase.envFile || [],
|
|
2592
|
+
volumes: resolvedBase.volumes || [],
|
|
2593
|
+
ports: resolvedBase.ports || []
|
|
2594
|
+
},
|
|
2550
2595
|
applied: {
|
|
2551
2596
|
containerName,
|
|
2552
2597
|
hostPath,
|
|
@@ -2569,6 +2614,138 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
2569
2614
|
};
|
|
2570
2615
|
}
|
|
2571
2616
|
|
|
2617
|
+
function resolveUniqueContainerName(ctx, state, baseName) {
|
|
2618
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
2619
|
+
const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
2620
|
+
const taken = new Set([...Object.keys(containerMap), ...historyNames]);
|
|
2621
|
+
// 基于历史最大编号 +1 命名,即使较小编号的副本被删除也不回收其编号,
|
|
2622
|
+
// 避免"删除 -copy1 后新副本又叫 -copy1"造成的名称语义混乱。
|
|
2623
|
+
const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2624
|
+
const pattern = new RegExp(`^${escapedBase}-copy(\\d+)$`);
|
|
2625
|
+
let maxN = 0;
|
|
2626
|
+
taken.forEach(name => {
|
|
2627
|
+
const matched = name.match(pattern);
|
|
2628
|
+
if (matched) {
|
|
2629
|
+
const n = Number(matched[1]);
|
|
2630
|
+
if (Number.isFinite(n) && n > maxN) {
|
|
2631
|
+
maxN = n;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
return `${baseName}-copy${maxN + 1}`;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
|
|
2639
|
+
if (runtimeSnapshot && typeof runtimeSnapshot === 'object') {
|
|
2640
|
+
return {
|
|
2641
|
+
hostPath: runtimeSnapshot.hostPath || '',
|
|
2642
|
+
containerPath: runtimeSnapshot.containerPath || '',
|
|
2643
|
+
imageName: runtimeSnapshot.imageName || '',
|
|
2644
|
+
imageVersion: runtimeSnapshot.imageVersion || '',
|
|
2645
|
+
containerMode: runtimeSnapshot.containerMode || '',
|
|
2646
|
+
shellPrefix: runtimeSnapshot.shellPrefix || '',
|
|
2647
|
+
shell: runtimeSnapshot.shell || '',
|
|
2648
|
+
shellSuffix: runtimeSnapshot.shellSuffix || '',
|
|
2649
|
+
agentPromptCommand: runtimeSnapshot.agentPromptCommand || '',
|
|
2650
|
+
yolo: runtimeSnapshot.yolo || '',
|
|
2651
|
+
env: runtimeSnapshot.env || {},
|
|
2652
|
+
envFile: runtimeSnapshot.envFile || [],
|
|
2653
|
+
volumes: runtimeSnapshot.volumes || [],
|
|
2654
|
+
ports: runtimeSnapshot.ports || []
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
const applied = fallbackHistory && fallbackHistory.applied && typeof fallbackHistory.applied === 'object'
|
|
2659
|
+
? fallbackHistory.applied
|
|
2660
|
+
: {};
|
|
2661
|
+
return {
|
|
2662
|
+
hostPath: applied.hostPath || '',
|
|
2663
|
+
containerPath: applied.containerPath || '',
|
|
2664
|
+
imageName: applied.imageName || '',
|
|
2665
|
+
imageVersion: applied.imageVersion || '',
|
|
2666
|
+
containerMode: applied.containerMode || '',
|
|
2667
|
+
shellPrefix: applied.shellPrefix || '',
|
|
2668
|
+
shell: applied.shell || '',
|
|
2669
|
+
shellSuffix: applied.shellSuffix || '',
|
|
2670
|
+
agentPromptCommand: typeof fallbackHistory.agentPromptCommand === 'string' ? fallbackHistory.agentPromptCommand : '',
|
|
2671
|
+
yolo: applied.yolo || '',
|
|
2672
|
+
env: {},
|
|
2673
|
+
envFile: [],
|
|
2674
|
+
volumes: [],
|
|
2675
|
+
ports: []
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function cloneWebAgentSessionsDeep(sourceHistory) {
|
|
2680
|
+
const cloned = {};
|
|
2681
|
+
const agents = sourceHistory && sourceHistory.agents && typeof sourceHistory.agents === 'object'
|
|
2682
|
+
? sourceHistory.agents
|
|
2683
|
+
: {};
|
|
2684
|
+
Object.keys(agents).forEach(agentId => {
|
|
2685
|
+
cloned[agentId] = JSON.parse(JSON.stringify(agents[agentId]));
|
|
2686
|
+
});
|
|
2687
|
+
return cloned;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
async function createClonedContainer(ctx, state, sourceContainerName, requestedName, options = {}) {
|
|
2691
|
+
const copyHistory = options.copyHistory === true;
|
|
2692
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sourceContainerName);
|
|
2693
|
+
const cloneFidelity = history.runtimeSnapshot ? 'full' : 'partial';
|
|
2694
|
+
const createOptions = buildCloneCreateOptionsFromSnapshot(history.runtimeSnapshot, history);
|
|
2695
|
+
|
|
2696
|
+
let finalName = '';
|
|
2697
|
+
if (requestedName) {
|
|
2698
|
+
validateContainerNameStrict(requestedName);
|
|
2699
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
2700
|
+
const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
|
|
2701
|
+
const taken = new Set([...Object.keys(containerMap), ...historyNames]);
|
|
2702
|
+
if (taken.has(requestedName)) {
|
|
2703
|
+
const err = new Error(`容器名已存在: ${requestedName}`);
|
|
2704
|
+
err.statusCode = 409;
|
|
2705
|
+
throw err;
|
|
2706
|
+
}
|
|
2707
|
+
finalName = requestedName;
|
|
2708
|
+
} else {
|
|
2709
|
+
finalName = resolveUniqueContainerName(ctx, state, sourceContainerName);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
createOptions.containerName = finalName;
|
|
2713
|
+
const runtime = buildCreateRuntime(ctx, state, { createOptions });
|
|
2714
|
+
await ensureWebContainer(ctx, state, runtime);
|
|
2715
|
+
setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
|
|
2716
|
+
|
|
2717
|
+
const patch = {
|
|
2718
|
+
applied: runtime.applied,
|
|
2719
|
+
runtimeSnapshot: runtime.runtimeSnapshot
|
|
2720
|
+
};
|
|
2721
|
+
if (copyHistory) {
|
|
2722
|
+
patch.agents = cloneWebAgentSessionsDeep(history);
|
|
2723
|
+
patch.agentPromptCommand = history.agentPromptCommand;
|
|
2724
|
+
}
|
|
2725
|
+
patchWebSessionHistory(state.webHistoryDir, finalName, patch);
|
|
2726
|
+
|
|
2727
|
+
return {
|
|
2728
|
+
name: finalName,
|
|
2729
|
+
applied: runtime.applied,
|
|
2730
|
+
sourceContainerName,
|
|
2731
|
+
cloneFidelity,
|
|
2732
|
+
resumeMayFail: copyHistory
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
function resolveContainerNamesByHostPath(ctx, state, hostPath) {
|
|
2737
|
+
const target = String(hostPath || '').trim();
|
|
2738
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
2739
|
+
const names = new Set([
|
|
2740
|
+
...Object.keys(containerMap),
|
|
2741
|
+
...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
|
|
2742
|
+
]);
|
|
2743
|
+
return Array.from(names).filter(name => {
|
|
2744
|
+
const summary = buildSessionSummary(ctx, state, containerMap, { containerName: name, agentId: WEB_DEFAULT_AGENT_ID });
|
|
2745
|
+
return summary && String(summary.hostPath || '').trim() === target;
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2572
2749
|
// Estimate container start time from "Up X hours/minutes/seconds" status string.
|
|
2573
2750
|
// Uses relative time to avoid Podman Machine VM clock drift issues.
|
|
2574
2751
|
function estimateStartTimeFromStatus(status) {
|
|
@@ -2763,6 +2940,34 @@ ${scriptSource}
|
|
|
2763
2940
|
__MANYOYO_NODE__`;
|
|
2764
2941
|
}
|
|
2765
2942
|
|
|
2943
|
+
function spawnContainerRawFileStream(ctx, containerName, filePath) {
|
|
2944
|
+
return spawn(
|
|
2945
|
+
ctx.dockerCmd,
|
|
2946
|
+
['exec', containerName, 'cat', '--', filePath],
|
|
2947
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
const webRawFileStreamCounts = new Map();
|
|
2952
|
+
|
|
2953
|
+
function acquireWebRawFileStreamSlot(containerName) {
|
|
2954
|
+
const current = webRawFileStreamCounts.get(containerName) || 0;
|
|
2955
|
+
if (current >= WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER) {
|
|
2956
|
+
return false;
|
|
2957
|
+
}
|
|
2958
|
+
webRawFileStreamCounts.set(containerName, current + 1);
|
|
2959
|
+
return true;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
function releaseWebRawFileStreamSlot(containerName) {
|
|
2963
|
+
const current = webRawFileStreamCounts.get(containerName) || 0;
|
|
2964
|
+
if (current <= 1) {
|
|
2965
|
+
webRawFileStreamCounts.delete(containerName);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
webRawFileStreamCounts.set(containerName, current - 1);
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2766
2971
|
function inferFileLanguage(filePath) {
|
|
2767
2972
|
const ext = path.extname(String(filePath || '')).toLowerCase();
|
|
2768
2973
|
return FILE_LANGUAGE_MAP[ext] || 'text';
|
|
@@ -2851,9 +3056,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
|
|
|
2851
3056
|
return buildWebContainerNodeCommand(`
|
|
2852
3057
|
// __MANYOYO_FS_READ__
|
|
2853
3058
|
const fs = require('fs');
|
|
3059
|
+
const path = require('path');
|
|
2854
3060
|
|
|
2855
3061
|
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
2856
3062
|
const maxBytes = ${String(maxBytes)};
|
|
3063
|
+
const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
|
|
2857
3064
|
|
|
2858
3065
|
function looksBinary(buffer) {
|
|
2859
3066
|
const length = Math.min(buffer.length, 4096);
|
|
@@ -2877,31 +3084,40 @@ try {
|
|
|
2877
3084
|
throw new Error('目标不是文件: ' + realPath);
|
|
2878
3085
|
}
|
|
2879
3086
|
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
const buffer = Buffer.alloc(readBytes);
|
|
2883
|
-
const fd = fs.openSync(realPath, 'r');
|
|
2884
|
-
try {
|
|
2885
|
-
fs.readSync(fd, buffer, 0, readBytes, 0);
|
|
2886
|
-
} finally {
|
|
2887
|
-
fs.closeSync(fd);
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
if (looksBinary(buffer)) {
|
|
3087
|
+
// 图片走 fs/raw 流式传输,这里只确认元信息,不把整份二进制读进内存/JSON
|
|
3088
|
+
if (imageExtensions.has(path.extname(realPath).toLowerCase())) {
|
|
2891
3089
|
process.stdout.write(JSON.stringify({
|
|
2892
3090
|
path: realPath,
|
|
2893
|
-
kind: '
|
|
2894
|
-
size
|
|
2895
|
-
truncated: maxBytes > 0 && size > maxBytes
|
|
3091
|
+
kind: 'image',
|
|
3092
|
+
size: stat.size
|
|
2896
3093
|
}));
|
|
2897
3094
|
} else {
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
}
|
|
3095
|
+
const size = stat.size;
|
|
3096
|
+
const readBytes = maxBytes > 0 ? Math.min(size, maxBytes) : size;
|
|
3097
|
+
const buffer = Buffer.alloc(readBytes);
|
|
3098
|
+
const fd = fs.openSync(realPath, 'r');
|
|
3099
|
+
try {
|
|
3100
|
+
fs.readSync(fd, buffer, 0, readBytes, 0);
|
|
3101
|
+
} finally {
|
|
3102
|
+
fs.closeSync(fd);
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
if (looksBinary(buffer)) {
|
|
3106
|
+
process.stdout.write(JSON.stringify({
|
|
3107
|
+
path: realPath,
|
|
3108
|
+
kind: 'binary',
|
|
3109
|
+
size,
|
|
3110
|
+
truncated: maxBytes > 0 && size > maxBytes
|
|
3111
|
+
}));
|
|
3112
|
+
} else {
|
|
3113
|
+
process.stdout.write(JSON.stringify({
|
|
3114
|
+
path: realPath,
|
|
3115
|
+
kind: 'text',
|
|
3116
|
+
size,
|
|
3117
|
+
truncated: maxBytes > 0 && size > maxBytes,
|
|
3118
|
+
content: buffer.toString('utf8')
|
|
3119
|
+
}));
|
|
3120
|
+
}
|
|
2905
3121
|
}
|
|
2906
3122
|
} catch (e) {
|
|
2907
3123
|
process.stdout.write(JSON.stringify({
|
|
@@ -2911,6 +3127,31 @@ try {
|
|
|
2911
3127
|
`);
|
|
2912
3128
|
}
|
|
2913
3129
|
|
|
3130
|
+
function buildContainerFileStatCommand(requestedPath) {
|
|
3131
|
+
return buildWebContainerNodeCommand(`
|
|
3132
|
+
// __MANYOYO_FS_STAT__
|
|
3133
|
+
const fs = require('fs');
|
|
3134
|
+
|
|
3135
|
+
const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
|
|
3136
|
+
|
|
3137
|
+
try {
|
|
3138
|
+
const realPath = fs.realpathSync(requestedPath);
|
|
3139
|
+
const stat = fs.statSync(realPath);
|
|
3140
|
+
if (!stat.isFile()) {
|
|
3141
|
+
throw new Error('目标不是文件: ' + realPath);
|
|
3142
|
+
}
|
|
3143
|
+
process.stdout.write(JSON.stringify({
|
|
3144
|
+
path: realPath,
|
|
3145
|
+
size: stat.size
|
|
3146
|
+
}));
|
|
3147
|
+
} catch (e) {
|
|
3148
|
+
process.stdout.write(JSON.stringify({
|
|
3149
|
+
error: e && e.message ? e.message : '读取文件失败'
|
|
3150
|
+
}));
|
|
3151
|
+
}
|
|
3152
|
+
`);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
2914
3155
|
function buildContainerFileWriteCommand(requestedPath, content) {
|
|
2915
3156
|
return buildWebContainerNodeCommand(`
|
|
2916
3157
|
// __MANYOYO_FS_WRITE__
|
|
@@ -3283,6 +3524,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
|
|
|
3283
3524
|
containerName,
|
|
3284
3525
|
agentId,
|
|
3285
3526
|
agentName: agentSession.agentName,
|
|
3527
|
+
agentRemark: agentSession.remark || '',
|
|
3528
|
+
containerRemark: history.remark || '',
|
|
3286
3529
|
status: containerInfo.status || 'history',
|
|
3287
3530
|
image: containerInfo.image || '',
|
|
3288
3531
|
createdAt,
|
|
@@ -3924,7 +4167,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
3924
4167
|
await ensureWebContainer(ctx, state, runtime);
|
|
3925
4168
|
setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
|
|
3926
4169
|
patchWebSessionHistory(state.webHistoryDir, runtime.containerName, {
|
|
3927
|
-
applied: runtime.applied
|
|
4170
|
+
applied: runtime.applied,
|
|
4171
|
+
runtimeSnapshot: runtime.runtimeSnapshot
|
|
3928
4172
|
});
|
|
3929
4173
|
sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
|
|
3930
4174
|
}
|
|
@@ -3948,6 +4192,125 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
3948
4192
|
});
|
|
3949
4193
|
}
|
|
3950
4194
|
},
|
|
4195
|
+
{
|
|
4196
|
+
method: 'POST',
|
|
4197
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/clone-config$/),
|
|
4198
|
+
handler: async match => {
|
|
4199
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
4200
|
+
if (!sessionRef) {
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
4203
|
+
if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
|
|
4204
|
+
sendJson(res, 400, { error: '该操作需在容器层级执行' });
|
|
4205
|
+
return;
|
|
4206
|
+
}
|
|
4207
|
+
const sourceContainerName = sessionRef.containerName;
|
|
4208
|
+
const historyExists = fs.existsSync(getWebHistoryFile(state.webHistoryDir, sourceContainerName));
|
|
4209
|
+
if (!historyExists && !ctx.containerExists(sourceContainerName)) {
|
|
4210
|
+
sendJson(res, 404, { error: `容器不存在: ${sourceContainerName}` });
|
|
4211
|
+
return;
|
|
4212
|
+
}
|
|
4213
|
+
const payload = await readJsonBody(req);
|
|
4214
|
+
const requestedName = pickFirstString(payload.containerName);
|
|
4215
|
+
try {
|
|
4216
|
+
const result = await createClonedContainer(ctx, state, sourceContainerName, requestedName, { copyHistory: false });
|
|
4217
|
+
sendJson(res, 200, result);
|
|
4218
|
+
} catch (e) {
|
|
4219
|
+
sendJson(res, e.statusCode || 400, { error: e.message || '创建相同配置容器失败' });
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
},
|
|
4223
|
+
{
|
|
4224
|
+
method: 'POST',
|
|
4225
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/duplicate$/),
|
|
4226
|
+
handler: async match => {
|
|
4227
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
4228
|
+
if (!sessionRef) {
|
|
4229
|
+
return;
|
|
4230
|
+
}
|
|
4231
|
+
if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
|
|
4232
|
+
sendJson(res, 400, { error: '该操作需在容器层级执行' });
|
|
4233
|
+
return;
|
|
4234
|
+
}
|
|
4235
|
+
const sourceContainerName = sessionRef.containerName;
|
|
4236
|
+
const historyExists = fs.existsSync(getWebHistoryFile(state.webHistoryDir, sourceContainerName));
|
|
4237
|
+
if (!historyExists && !ctx.containerExists(sourceContainerName)) {
|
|
4238
|
+
sendJson(res, 404, { error: `容器不存在: ${sourceContainerName}` });
|
|
4239
|
+
return;
|
|
4240
|
+
}
|
|
4241
|
+
const payload = await readJsonBody(req);
|
|
4242
|
+
const requestedName = pickFirstString(payload.containerName);
|
|
4243
|
+
try {
|
|
4244
|
+
const result = await createClonedContainer(ctx, state, sourceContainerName, requestedName, { copyHistory: true });
|
|
4245
|
+
sendJson(res, 200, result);
|
|
4246
|
+
} catch (e) {
|
|
4247
|
+
sendJson(res, e.statusCode || 400, { error: e.message || '复制容器失败' });
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
},
|
|
4251
|
+
{
|
|
4252
|
+
method: 'POST',
|
|
4253
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/container-remark$/),
|
|
4254
|
+
handler: async match => {
|
|
4255
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
4256
|
+
if (!sessionRef) {
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
|
|
4260
|
+
sendJson(res, 400, { error: '该操作需在容器层级执行' });
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
const payload = await readJsonBody(req);
|
|
4264
|
+
const remark = normalizeRemarkText(payload.remark);
|
|
4265
|
+
patchWebSessionHistory(state.webHistoryDir, sessionRef.containerName, { remark });
|
|
4266
|
+
sendJson(res, 200, { containerName: sessionRef.containerName, remark });
|
|
4267
|
+
}
|
|
4268
|
+
},
|
|
4269
|
+
{
|
|
4270
|
+
method: 'POST',
|
|
4271
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-remark$/),
|
|
4272
|
+
handler: async match => {
|
|
4273
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
4274
|
+
if (!sessionRef) {
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
const payload = await readJsonBody(req);
|
|
4278
|
+
const remark = normalizeRemarkText(payload.remark);
|
|
4279
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { remark });
|
|
4280
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), remark });
|
|
4281
|
+
}
|
|
4282
|
+
},
|
|
4283
|
+
{
|
|
4284
|
+
method: 'POST',
|
|
4285
|
+
match: currentPath => currentPath === '/api/sessions/by-directory/remove' ? [] : null,
|
|
4286
|
+
handler: async () => {
|
|
4287
|
+
const payload = await readJsonBody(req);
|
|
4288
|
+
const hostPath = typeof payload.hostPath === 'string' ? payload.hostPath : '';
|
|
4289
|
+
if (!hostPath.trim()) {
|
|
4290
|
+
sendJson(res, 400, { error: 'hostPath 不能为空' });
|
|
4291
|
+
return;
|
|
4292
|
+
}
|
|
4293
|
+
const targets = resolveContainerNamesByHostPath(ctx, state, hostPath);
|
|
4294
|
+
if (!targets.length) {
|
|
4295
|
+
sendJson(res, 404, { error: '未找到该工作目录下的容器' });
|
|
4296
|
+
return;
|
|
4297
|
+
}
|
|
4298
|
+
const removed = [];
|
|
4299
|
+
const errors = [];
|
|
4300
|
+
targets.forEach(name => {
|
|
4301
|
+
try {
|
|
4302
|
+
if (ctx.containerExists(name)) {
|
|
4303
|
+
ctx.removeContainer(name);
|
|
4304
|
+
}
|
|
4305
|
+
removeWebSessionHistory(state.webHistoryDir, name);
|
|
4306
|
+
removed.push(name);
|
|
4307
|
+
} catch (e) {
|
|
4308
|
+
errors.push({ name, error: e.message || String(e) });
|
|
4309
|
+
}
|
|
4310
|
+
});
|
|
4311
|
+
sendJson(res, 200, { hostPath, removed, errors });
|
|
4312
|
+
}
|
|
4313
|
+
},
|
|
3951
4314
|
{
|
|
3952
4315
|
method: 'GET',
|
|
3953
4316
|
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
|
|
@@ -4027,6 +4390,73 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4027
4390
|
sendJson(res, 200, payload);
|
|
4028
4391
|
}
|
|
4029
4392
|
},
|
|
4393
|
+
{
|
|
4394
|
+
method: 'GET',
|
|
4395
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/raw$/),
|
|
4396
|
+
handler: async match => {
|
|
4397
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
4398
|
+
if (!sessionRef) {
|
|
4399
|
+
return;
|
|
4400
|
+
}
|
|
4401
|
+
const requestUrl = new URL(req.url || '/api/sessions/x/fs/raw', 'http://localhost');
|
|
4402
|
+
const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
|
|
4403
|
+
if (!targetPath) {
|
|
4404
|
+
sendJson(res, 400, { error: 'path 不能为空' });
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
4408
|
+
const contentType = IMAGE_EXTENSION_CONTENT_TYPES[ext];
|
|
4409
|
+
if (!contentType) {
|
|
4410
|
+
sendJson(res, 400, { error: '不支持预览该文件类型' });
|
|
4411
|
+
return;
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
4415
|
+
const statPayload = await execJsonCommandInWebContainer(
|
|
4416
|
+
ctx,
|
|
4417
|
+
sessionRef.containerName,
|
|
4418
|
+
buildContainerFileStatCommand(targetPath)
|
|
4419
|
+
);
|
|
4420
|
+
if (statPayload && statPayload.error) {
|
|
4421
|
+
sendJson(res, 404, { error: statPayload.error });
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
if (!acquireWebRawFileStreamSlot(sessionRef.containerName)) {
|
|
4426
|
+
sendJson(res, 429, { error: '预览并发已达上限,请稍后重试' });
|
|
4427
|
+
return;
|
|
4428
|
+
}
|
|
4429
|
+
|
|
4430
|
+
let released = false;
|
|
4431
|
+
const releaseOnce = () => {
|
|
4432
|
+
if (released) {
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4435
|
+
released = true;
|
|
4436
|
+
releaseWebRawFileStreamSlot(sessionRef.containerName);
|
|
4437
|
+
};
|
|
4438
|
+
|
|
4439
|
+
const child = spawnContainerRawFileStream(ctx, sessionRef.containerName, statPayload.path);
|
|
4440
|
+
// stderr 单独消费,绝不混入响应体,避免管道缓冲区打满导致 cat 进程卡死
|
|
4441
|
+
child.stderr.on('data', () => {});
|
|
4442
|
+
child.on('error', () => releaseOnce());
|
|
4443
|
+
child.on('close', () => releaseOnce());
|
|
4444
|
+
res.on('close', () => {
|
|
4445
|
+
releaseOnce();
|
|
4446
|
+
if (!child.killed) {
|
|
4447
|
+
child.kill();
|
|
4448
|
+
}
|
|
4449
|
+
});
|
|
4450
|
+
|
|
4451
|
+
res.writeHead(200, {
|
|
4452
|
+
'Content-Type': contentType,
|
|
4453
|
+
'Content-Length': String(statPayload.size),
|
|
4454
|
+
'X-Content-Type-Options': 'nosniff',
|
|
4455
|
+
'Cache-Control': 'no-store'
|
|
4456
|
+
});
|
|
4457
|
+
child.stdout.pipe(res);
|
|
4458
|
+
}
|
|
4459
|
+
},
|
|
4030
4460
|
{
|
|
4031
4461
|
method: 'PUT',
|
|
4032
4462
|
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/write$/),
|