neoctl-web 0.1.0
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 +238 -0
- package/bin/neow.mjs +137 -0
- package/core-runtime.mjs +55 -0
- package/cpa-quota.mjs +209 -0
- package/dist/assets/index-BSFq6wfd.css +1 -0
- package/dist/assets/index-H7num-0s.js +119 -0
- package/dist/favicon.svg +3 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +28 -0
- package/memory-monitor.mjs +150 -0
- package/package.json +64 -0
- package/plugin-settings.mjs +67 -0
- package/plugins/downloads/downloads.mjs +147 -0
- package/plugins/downloads/index.mjs +19 -0
- package/plugins/downloads/neo-plugin.json +9 -0
- package/plugins/xhs-artifact/artifacts.mjs +388 -0
- package/plugins/xhs-artifact/editor-page.mjs +73 -0
- package/plugins/xhs-artifact/index.mjs +48 -0
- package/plugins/xhs-artifact/neo-plugin.json +9 -0
- package/plugins/xhs-artifact/xhs-artifact-contract.mjs +125 -0
- package/plugins.mjs +111 -0
- package/runtime-router-cleanup.mjs +152 -0
- package/runtime-workspaces.mjs +515 -0
- package/server.mjs +450 -0
- package/tool-settings.mjs +80 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
parseXhsArtifactToolOutput,
|
|
7
|
+
XHS_ARTIFACT_EDITOR_HINT,
|
|
8
|
+
XHS_ARTIFACT_INPUT_SCHEMA,
|
|
9
|
+
XHS_IMAGE_FIELDS,
|
|
10
|
+
XHS_PAYLOAD_FIELDS,
|
|
11
|
+
} from './xhs-artifact-contract.mjs';
|
|
12
|
+
|
|
13
|
+
export class XhsArtifactRegistry {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.entries = new Map();
|
|
16
|
+
this.storageDir = path.resolve(options.storageDir || path.join(process.cwd(), '.neoctl-web', 'xhs-artifacts'));
|
|
17
|
+
this.sessionsDir = path.resolve(options.sessionsDir || path.join(os.homedir(), '.neoctl', 'sessions'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
add(entry) {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const artifact = normalizeArtifact({
|
|
23
|
+
id: crypto.randomUUID(),
|
|
24
|
+
type: 'xhs-post',
|
|
25
|
+
title: '',
|
|
26
|
+
payload: {},
|
|
27
|
+
content: '',
|
|
28
|
+
sessionId: undefined,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
...entry,
|
|
32
|
+
});
|
|
33
|
+
this.entries.set(artifact.id, artifact);
|
|
34
|
+
this.persist(artifact);
|
|
35
|
+
return cloneArtifact(artifact);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(id, sessionId) {
|
|
39
|
+
const artifactId = safeArtifactId(id);
|
|
40
|
+
if (!artifactId) return undefined;
|
|
41
|
+
const artifact = this.entries.get(artifactId) || this.load(artifactId) || this.recoverFromTranscript(artifactId, sessionId);
|
|
42
|
+
if (artifact) this.entries.set(artifact.id, artifact);
|
|
43
|
+
if (!artifactBelongsToSession(artifact, sessionId)) return undefined;
|
|
44
|
+
return artifact ? cloneArtifact(artifact) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
update(id, patch, sessionId) {
|
|
48
|
+
const artifact = this.get(id, sessionId);
|
|
49
|
+
if (!artifact) return undefined;
|
|
50
|
+
if (typeof patch.title === 'string') artifact.title = patch.title.trim().slice(0, 160);
|
|
51
|
+
if (patch.payload !== undefined) artifact.payload = validateEditorPayload(patch.payload);
|
|
52
|
+
if (typeof patch.content === 'string') artifact.content = patch.content;
|
|
53
|
+
artifact.updatedAt = Date.now();
|
|
54
|
+
this.entries.set(artifact.id, artifact);
|
|
55
|
+
this.persist(artifact);
|
|
56
|
+
return cloneArtifact(artifact);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
load(id) {
|
|
60
|
+
try {
|
|
61
|
+
return normalizeArtifact(JSON.parse(fs.readFileSync(this.artifactFile(id), 'utf8')));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code !== 'ENOENT') console.warn(`failed to load xhs artifact ${id}:`, error);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
persist(artifact) {
|
|
69
|
+
fs.mkdirSync(this.storageDir, { recursive: true });
|
|
70
|
+
const target = this.artifactFile(artifact.id);
|
|
71
|
+
const temporary = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
72
|
+
fs.writeFileSync(temporary, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
|
73
|
+
fs.renameSync(temporary, target);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
recoverFromTranscript(id, sessionId) {
|
|
77
|
+
const safeSessionId = safeSessionDirectoryName(sessionId);
|
|
78
|
+
if (!safeSessionId) return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const transcript = fs.readFileSync(path.join(this.sessionsDir, safeSessionId, 'transcript.jsonl'), 'utf8');
|
|
81
|
+
const lines = transcript.split(/\r?\n/).filter(Boolean);
|
|
82
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
83
|
+
let entry;
|
|
84
|
+
try { entry = JSON.parse(lines[index]); } catch { continue; }
|
|
85
|
+
for (const block of entry?.message?.blocks || []) {
|
|
86
|
+
if (block?.type !== 'tool_result' || !['open_xhs_artifact_editor', 'read_xhs_artifact'].includes(block.name)) continue;
|
|
87
|
+
const recovered = parseXhsArtifactToolOutput(block.output);
|
|
88
|
+
if (String(recovered?.id || '') !== id) continue;
|
|
89
|
+
const artifact = normalizeArtifact({ ...recovered, sessionId: recovered.sessionId || safeSessionId });
|
|
90
|
+
this.persist(artifact);
|
|
91
|
+
return artifact;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code !== 'ENOENT') console.warn(`failed to recover xhs artifact ${id}:`, error);
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
artifactFile(id) {
|
|
101
|
+
return path.join(this.storageDir, `${id}.json`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createOpenXhsArtifactEditorTool(options) {
|
|
106
|
+
return {
|
|
107
|
+
name: 'open_xhs_artifact_editor',
|
|
108
|
+
description: XHS_ARTIFACT_EDITOR_HINT,
|
|
109
|
+
inputSchema: XHS_ARTIFACT_INPUT_SCHEMA,
|
|
110
|
+
metadata: {
|
|
111
|
+
readOnly: false,
|
|
112
|
+
concurrent: true,
|
|
113
|
+
visible: true,
|
|
114
|
+
requiresApproval: false,
|
|
115
|
+
maxResultSizeChars: 60000,
|
|
116
|
+
},
|
|
117
|
+
validate(input) {
|
|
118
|
+
const payload = validateEditorPayload(input?.payload);
|
|
119
|
+
return {
|
|
120
|
+
artifactId: String(input?.artifact_id || '').trim(),
|
|
121
|
+
title: payload.title,
|
|
122
|
+
payload,
|
|
123
|
+
content: '',
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
async execute(input, context) {
|
|
127
|
+
const sessionId = context?.session?.sessionId;
|
|
128
|
+
const artifact = input.artifactId
|
|
129
|
+
? options.registry.update(input.artifactId, { title: input.title, payload: input.payload, content: input.content }, sessionId)
|
|
130
|
+
: options.registry.add({
|
|
131
|
+
title: input.title,
|
|
132
|
+
payload: input.payload,
|
|
133
|
+
content: input.content,
|
|
134
|
+
sessionId: context.session?.sessionId,
|
|
135
|
+
});
|
|
136
|
+
if (!artifact) throw new Error(`xhs artifact not found: ${input.artifactId}; call read_xhs_artifact first or omit artifact_id to create a new editor`);
|
|
137
|
+
const action = input.artifactId ? 'Updated' : 'Opened';
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
output: {
|
|
141
|
+
artifact: clientArtifact(artifact),
|
|
142
|
+
action: action.toLowerCase(),
|
|
143
|
+
_ui: xhsArtifactPresentation(artifact),
|
|
144
|
+
},
|
|
145
|
+
summary: `${action} Xiaohongshu editor ${artifact.id}`,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function xhsArtifactPresentation(artifact, sessionId = artifact?.sessionId) {
|
|
152
|
+
if (!artifact?.id) return undefined;
|
|
153
|
+
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
|
|
154
|
+
return {
|
|
155
|
+
title: '编辑小红书笔记',
|
|
156
|
+
bodyTitle: artifact.title || '小红书笔记',
|
|
157
|
+
text: '可编辑笔记已就绪。',
|
|
158
|
+
presentationLevel: 'primary',
|
|
159
|
+
resources: [{
|
|
160
|
+
kind: 'embed',
|
|
161
|
+
url: `/api/xhs-artifacts/${encodeURIComponent(artifact.id)}/editor${query}`,
|
|
162
|
+
label: artifact.title || '小红书笔记编辑器',
|
|
163
|
+
height: 720,
|
|
164
|
+
}],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createReadXhsArtifactTool(options) {
|
|
169
|
+
return {
|
|
170
|
+
name: 'read_xhs_artifact',
|
|
171
|
+
description: 'Read the latest user-edited Xiaohongshu editor state. Before revising, call this with the artifact id, preserve the returned user edits, then call open_xhs_artifact_editor with artifact_id set to the same id and a complete exact payload.',
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: { id: { type: 'string', description: 'Artifact id returned by open_xhs_artifact_editor.' } },
|
|
175
|
+
required: ['id'],
|
|
176
|
+
additionalProperties: false,
|
|
177
|
+
},
|
|
178
|
+
metadata: {
|
|
179
|
+
readOnly: true,
|
|
180
|
+
concurrent: true,
|
|
181
|
+
visible: true,
|
|
182
|
+
requiresApproval: false,
|
|
183
|
+
maxResultSizeChars: 60000,
|
|
184
|
+
},
|
|
185
|
+
validate(input) {
|
|
186
|
+
const id = String(input?.id || '').trim();
|
|
187
|
+
if (!id) throw new Error('id is required');
|
|
188
|
+
return { id };
|
|
189
|
+
},
|
|
190
|
+
async execute(input, context) {
|
|
191
|
+
const artifact = options.registry.get(input.id, context?.session?.sessionId);
|
|
192
|
+
if (!artifact) throw new Error(`xhs artifact not found: ${input.id}`);
|
|
193
|
+
return { ok: true, output: { artifact: clientArtifact(artifact) }, summary: '已读取稿件' };
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function serveXhsArtifact(registry, req, res, id, readJsonBody, sessionId) {
|
|
199
|
+
if (req.method === 'GET') {
|
|
200
|
+
const artifact = registry.get(id, sessionId);
|
|
201
|
+
return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
|
|
202
|
+
}
|
|
203
|
+
if (req.method === 'PUT') {
|
|
204
|
+
const body = await readJsonBody(req);
|
|
205
|
+
assertExactKeys(body, ['title', 'payload'], 'request');
|
|
206
|
+
const payload = validateEditorPayload(body?.payload);
|
|
207
|
+
const artifact = registry.update(id, { title: payload.title, payload }, sessionId);
|
|
208
|
+
return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
|
|
209
|
+
}
|
|
210
|
+
return sendJson(res, { error: 'method not allowed' }, 405);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function normalizeArtifact(value) {
|
|
214
|
+
const payload = validateEditorPayload(value.payload);
|
|
215
|
+
return {
|
|
216
|
+
id: String(value.id),
|
|
217
|
+
type: String(value.type || 'xhs-post'),
|
|
218
|
+
title: payload.title,
|
|
219
|
+
payload,
|
|
220
|
+
content: String(value.content || ''),
|
|
221
|
+
sessionId: value.sessionId,
|
|
222
|
+
createdAt: Number(value.createdAt || Date.now()),
|
|
223
|
+
updatedAt: Number(value.updatedAt || Date.now()),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function clientArtifact(artifact) {
|
|
228
|
+
return {
|
|
229
|
+
id: artifact.id,
|
|
230
|
+
type: artifact.type,
|
|
231
|
+
title: artifact.title,
|
|
232
|
+
payload: clientPayload(artifact.payload),
|
|
233
|
+
sessionId: artifact.sessionId,
|
|
234
|
+
createdAt: artifact.createdAt,
|
|
235
|
+
updatedAt: artifact.updatedAt,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function clientPayload(payload) {
|
|
240
|
+
return {
|
|
241
|
+
title: payload.title,
|
|
242
|
+
body: payload.body,
|
|
243
|
+
interaction: payload.interaction,
|
|
244
|
+
hashtags: [...payload.hashtags],
|
|
245
|
+
images: payload.images.map(clientImage),
|
|
246
|
+
review: payload.review,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function clientImage(image) {
|
|
251
|
+
return {
|
|
252
|
+
url: safeImageUrl(image.url),
|
|
253
|
+
caption: image.caption,
|
|
254
|
+
overlay: image.overlay,
|
|
255
|
+
note: image.note,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateEditorPayload(value) {
|
|
260
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
261
|
+
throw contractError('payload must be an object');
|
|
262
|
+
}
|
|
263
|
+
assertExactKeys(value, XHS_PAYLOAD_FIELDS, 'payload');
|
|
264
|
+
|
|
265
|
+
const title = requireEditorString(value.title, 'payload.title').trim().slice(0, 160);
|
|
266
|
+
const body = requireEditorString(value.body, 'payload.body').trim();
|
|
267
|
+
const interaction = requireEditorString(value.interaction, 'payload.interaction').trim();
|
|
268
|
+
const review = requireEditorString(value.review, 'payload.review').trim();
|
|
269
|
+
if (!title) throw contractError('payload.title must contain the final post title');
|
|
270
|
+
if (!body) throw contractError('payload.body must contain final publish-ready正文 only');
|
|
271
|
+
validateBodyBoundaries(body, title);
|
|
272
|
+
if (!Array.isArray(value.hashtags)) throw contractError('payload.hashtags must be an array of strings such as ["#话题"]');
|
|
273
|
+
if (!Array.isArray(value.images)) throw contractError('payload.images must be an array of {url, caption, overlay, note} objects');
|
|
274
|
+
if (!value.images.length) throw contractError('payload.images must contain at least one image or planned placeholder');
|
|
275
|
+
|
|
276
|
+
const hashtags = value.hashtags.map((tag, index) => {
|
|
277
|
+
if (typeof tag !== 'string') throw contractError(`payload.hashtags[${index}] must be a string`);
|
|
278
|
+
const normalized = tag.trim().replace(/^#/u, '#');
|
|
279
|
+
if (!/^#[^\s#]+$/u.test(normalized)) throw contractError(`payload.hashtags[${index}] must be exactly one #topic without spaces`);
|
|
280
|
+
return normalized.slice(0, 100);
|
|
281
|
+
});
|
|
282
|
+
const images = value.images.map((image, index) => validateEditorImage(image, index));
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
title,
|
|
286
|
+
body,
|
|
287
|
+
interaction,
|
|
288
|
+
hashtags: [...new Set(hashtags)].slice(0, 40),
|
|
289
|
+
images,
|
|
290
|
+
review,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateEditorImage(value, index) {
|
|
295
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
296
|
+
throw contractError(`payload.images[${index}] must be an object with url, caption, overlay, note`);
|
|
297
|
+
}
|
|
298
|
+
assertExactKeys(value, XHS_IMAGE_FIELDS, `payload.images[${index}]`);
|
|
299
|
+
const rawUrl = requireEditorString(value.url, `payload.images[${index}].url`).trim();
|
|
300
|
+
const url = safeImageUrl(rawUrl);
|
|
301
|
+
if (rawUrl && !url) {
|
|
302
|
+
throw contractError(`payload.images[${index}].url must be a real http(s) URL, /api/ URL, or absolute local image path; use "" for a placeholder`);
|
|
303
|
+
}
|
|
304
|
+
const caption = requireEditorString(value.caption, `payload.images[${index}].caption`).trim();
|
|
305
|
+
const overlay = requireEditorString(value.overlay, `payload.images[${index}].overlay`).trim();
|
|
306
|
+
const note = requireEditorString(value.note, `payload.images[${index}].note`).trim();
|
|
307
|
+
if (!url && !caption && !note) throw contractError(`payload.images[${index}] is an empty placeholder; describe the planned image in caption or note`);
|
|
308
|
+
return {
|
|
309
|
+
url,
|
|
310
|
+
caption: caption.slice(0, 1000),
|
|
311
|
+
overlay: overlay.slice(0, 500),
|
|
312
|
+
note: note.slice(0, 2000),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function requireEditorString(value, field) {
|
|
317
|
+
if (typeof value !== 'string') throw contractError(`${field} must be a string`);
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function safeImageUrl(...values) {
|
|
322
|
+
const url = String(values.find((value) => typeof value === 'string') || '').trim();
|
|
323
|
+
if (!url || /^data:/i.test(url)) return '';
|
|
324
|
+
if (/^(?:https?:|\/api\/)/i.test(url)) return url.slice(0, 2048);
|
|
325
|
+
if (isLocalFilePath(url)) return `/api/local-images/${encodeURIComponent(Buffer.from(url, 'utf8').toString('base64url'))}`;
|
|
326
|
+
return '';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isLocalFilePath(value) {
|
|
330
|
+
return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('/');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function assertExactKeys(value, expected, path) {
|
|
334
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw contractError(`${path} must be an object`);
|
|
335
|
+
const actual = Object.keys(value);
|
|
336
|
+
const missing = expected.filter((key) => !actual.includes(key));
|
|
337
|
+
const extra = actual.filter((key) => !expected.includes(key));
|
|
338
|
+
if (missing.length || extra.length) {
|
|
339
|
+
const details = [
|
|
340
|
+
missing.length ? `missing ${missing.join(', ')}` : '',
|
|
341
|
+
extra.length ? `unexpected ${extra.join(', ')}` : '',
|
|
342
|
+
].filter(Boolean).join('; ');
|
|
343
|
+
throw contractError(`${path} must contain exactly ${expected.join(', ')} (${details})`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function validateBodyBoundaries(body, title) {
|
|
348
|
+
const firstLine = body.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
349
|
+
if (firstLine === title || firstLine.replace(/^#{1,6}\s*/, '') === title) {
|
|
350
|
+
throw contractError('payload.body must not repeat payload.title as its first line');
|
|
351
|
+
}
|
|
352
|
+
if (/```|~~~/.test(body)) throw contractError('payload.body must not contain fenced Markdown or JSON');
|
|
353
|
+
if (/^\s*#{1,6}\s*(?:标题|正文|笔记正文|发布文案|配图|图片方案|话题标签|标签|审核|review)\s*[::]?\s*$/imu.test(body)) {
|
|
354
|
+
throw contractError('payload.body must not contain editor section headings');
|
|
355
|
+
}
|
|
356
|
+
if (/(?:^|\s)#[^\s#]+/u.test(body)) throw contractError('payload.body must not contain hashtags; put them in payload.hashtags');
|
|
357
|
+
if (/^\s*\{[\s\S]*"(?:title|body|images|hashtags)"\s*:/u.test(body)) {
|
|
358
|
+
throw contractError('payload.body must not contain serialized tool JSON');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function contractError(message) {
|
|
363
|
+
return new Error(`Invalid Xiaohongshu editor payload: ${message}. Retry open_xhs_artifact_editor with the exact documented schema; do not send Markdown or alternate fields.`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function safeArtifactId(value) {
|
|
367
|
+
const id = String(value || '').trim();
|
|
368
|
+
return id && /^[A-Za-z0-9._-]+$/u.test(id) && path.basename(id) === id ? id : '';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function safeSessionDirectoryName(value) {
|
|
372
|
+
const id = String(value || '').trim();
|
|
373
|
+
return id && /^[A-Za-z0-9._:-]+$/u.test(id) && !id.includes('..') && path.basename(id) === id ? id : '';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function artifactBelongsToSession(artifact, sessionId) {
|
|
377
|
+
const expected = String(sessionId || '').trim();
|
|
378
|
+
return Boolean(artifact) && (!expected || !artifact.sessionId || artifact.sessionId === expected);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function cloneArtifact(artifact) {
|
|
382
|
+
return JSON.parse(JSON.stringify(artifact));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function sendJson(res, value, status = 200) {
|
|
386
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
387
|
+
res.end(JSON.stringify(value));
|
|
388
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
function escapeHtml(value) {
|
|
2
|
+
return String(value ?? '')
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function serialize(value) {
|
|
11
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function serveXhsEditorPage(res, artifact, options = {}) {
|
|
15
|
+
const sessionId = String(options.sessionId || artifact.sessionId || '');
|
|
16
|
+
const apiUrl = `/api/xhs-artifacts/${encodeURIComponent(artifact.id)}${sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ''}`;
|
|
17
|
+
const html = renderXhsEditorPage(artifact, apiUrl);
|
|
18
|
+
res.writeHead(200, {
|
|
19
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
20
|
+
'Content-Length': String(Buffer.byteLength(html)),
|
|
21
|
+
'Cache-Control': 'no-store',
|
|
22
|
+
'Content-Security-Policy': "default-src 'self'; img-src 'self' data: blob: https: http:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
|
23
|
+
});
|
|
24
|
+
res.end(html);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderXhsEditorPage(artifact, apiUrl) {
|
|
28
|
+
return `<!doctype html>
|
|
29
|
+
<html lang="zh-CN">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
33
|
+
<title>${escapeHtml(artifact.title || '小红书笔记')}</title>
|
|
34
|
+
<style>
|
|
35
|
+
:root{color-scheme:light;--bg:#fff;--panel:#fff;--panel2:#f7f7f7;--media:#f5f5f5;--text:#191919;--copy:#333;--muted:#8b8b8b;--line:#ededed;--accent:#ff2442;--soft:#fff0f2;--shadow:0 16px 48px rgba(0,0,0,.1);--neo-surface:#fff;--neo-text:#111827;--neo-border:#1f2937;--neo-shadow:#cbd5e1;--neo-pink:#ed68ac;--neo-active:#111827;--neo-active-text:#fff}
|
|
36
|
+
:root[data-theme=dark]{color-scheme:dark;--bg:#09090b;--panel:#111113;--panel2:#18181b;--media:#050505;--text:#f5f5f5;--copy:#f5f5f5;--muted:#96969d;--line:#2b2b2f;--accent:#ff2442;--soft:#35151c;--shadow:0 20px 60px rgba(0,0,0,.5);--neo-surface:#303842;--neo-text:#f3f4f6;--neo-border:#d8dee9;--neo-shadow:#111318;--neo-pink:#9d416f;--neo-active:#f3f4f6;--neo-active-text:#111827}
|
|
37
|
+
*{box-sizing:border-box;scrollbar-width:none}*::-webkit-scrollbar{display:none}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}button,input,textarea{font:inherit}button{cursor:pointer}
|
|
38
|
+
.shell{display:grid;gap:12px;padding:10px;background:var(--bg)}.head{display:flex;min-height:42px;align-items:center;justify-content:flex-end;border:0;background:transparent;padding:0 0 2px;color:var(--neo-text);box-shadow:none}
|
|
39
|
+
.actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:nowrap}.segmented{display:flex;gap:6px;background:transparent}.segmented button,.save,.screen,.upload,.small{background:var(--neo-surface);color:var(--neo-text);padding:7px 12px;font-weight:700}.segmented button,.save,.screen{border:2px solid var(--neo-border);border-radius:0;box-shadow:2px 2px 0 var(--neo-shadow)}.segmented button.active{background:var(--neo-active);color:var(--neo-active-text)}.save{background:var(--neo-pink);padding-inline:18px}.screen{background:var(--neo-surface)}.segmented button:active,.save:active,.screen:active{transform:translate(2px,2px);box-shadow:none}.save:disabled{opacity:.55}.status{min-width:42px;color:var(--muted);font-size:12px;line-height:1;white-space:nowrap;pointer-events:none}.status:empty{display:none}
|
|
40
|
+
.grid{display:grid;grid-template-columns:minmax(300px,.9fr) minmax(380px,1.1fr);gap:14px;align-items:start}.grid.preview{display:block;max-width:1050px;width:100%;margin:auto}.grid.edit{display:block;max-width:900px;width:100%;margin:auto}.hidden{display:none!important}
|
|
41
|
+
.preview-card,.form{border-radius:18px;box-shadow:none}.preview-card{display:grid;grid-template-columns:minmax(0,1.42fr) minmax(310px,.78fr);min-height:610px;overflow:hidden;border:1px solid var(--line);background:var(--panel);color:var(--text)}.grid:not(.preview) .preview-card{display:block;min-height:0}.media{position:relative;min-height:610px;background:var(--media);display:grid;place-items:center;overflow:hidden}.grid:not(.preview) .media{min-height:340px;aspect-ratio:4/3}.media img{width:100%;height:100%;object-fit:cover}.media.image-missing{background:radial-gradient(circle at 18% 18%,rgba(255,36,66,.12),transparent 26%),radial-gradient(circle at 82% 78%,rgba(255,137,154,.16),transparent 28%),var(--media)}.placeholder{display:grid;justify-items:center;gap:8px;width:min(320px,70%);padding:30px 24px;color:var(--muted);text-align:center}.placeholder-art{display:grid;place-items:center;width:68px;height:68px;margin-bottom:5px;border-radius:20px;background:var(--soft);color:var(--accent);font-size:30px;transform:rotate(-4deg)}.placeholder strong{color:var(--text);font-size:16px}.placeholder small{max-width:260px;font-size:13px;line-height:1.55}.overlay{position:absolute;left:24px;right:24px;bottom:26px;border-radius:10px;color:white;font-size:26px;font-weight:900;text-align:center;text-shadow:0 2px 12px #000;background:rgba(0,0,0,.34);padding:9px 12px}.arrows{position:absolute;left:14px;right:14px;top:50%;display:flex;justify-content:space-between;pointer-events:none}.arrows button{pointer-events:auto;width:36px;height:36px;border:0;border-radius:50%;background:rgba(0,0,0,.48);color:white;font-size:22px;backdrop-filter:blur(8px)}.dots{position:absolute;left:0;right:0;bottom:9px;text-align:center;color:white;text-shadow:0 1px 4px #000}.media.image-missing .dots{color:var(--muted);text-shadow:none}.body{position:relative;display:flex;min-width:0;max-height:610px;flex-direction:column;padding:22px;overflow:auto;background:var(--panel)}.author{display:flex;align-items:center;gap:10px;margin-bottom:20px;font-weight:700}.avatar{display:grid;place-items:center;width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,#ff8a9c,#ff2442);color:white}.author-name{min-width:0;flex:1}.follow{border:0;border-radius:999px;background:var(--accent);color:white;padding:7px 20px;font-weight:700}h1{margin:0 0 10px;font-size:19px;line-height:1.4}.preview-card .copy{white-space:pre-wrap;margin:0;color:var(--copy)}.preview-card .interaction{margin:14px 0 0;color:var(--copy)}.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:15px}.tags span{color:#3d6dcc;font-weight:550}:root[data-theme=dark] .tags span{color:#8ab4f8}.post-meta{margin-top:18px;padding-bottom:18px;border-bottom:1px solid var(--line);color:var(--muted);font-size:12px}.social{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;padding-top:18px;color:var(--muted)}.social span{display:inline-flex;align-items:center;gap:4px}.social b{color:var(--text);font-size:17px}
|
|
42
|
+
.form{display:grid;gap:13px;padding:18px}.field{display:grid;gap:5px}.field span,.images-head strong{font-size:12px;font-weight:700}.field input,.field textarea{width:100%;border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--text);padding:10px 12px;outline:none}.field textarea{min-height:112px;resize:vertical}.field input:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.images-head{display:flex;align-items:center;justify-content:space-between}.upload,.small{border:1px solid var(--line);border-radius:999px;background:var(--panel2)}.image-row{display:grid;grid-template-columns:92px minmax(0,1fr);gap:10px;border-top:1px solid var(--line);padding:12px 0}.thumb{width:92px;aspect-ratio:1;border:1px solid var(--line);border-radius:10px;background:var(--panel2);overflow:hidden;padding:0}.thumb img{width:100%;height:100%;object-fit:cover}.image-fields{display:grid;gap:7px}.image-fields input{padding:7px 9px}.row-actions{display:flex;gap:7px}.danger{color:var(--accent)}.empty{color:var(--muted);padding:14px;border:1px dashed var(--line);border-radius:10px}
|
|
43
|
+
:fullscreen{overflow:auto;background:var(--bg)}:fullscreen .shell{min-height:100vh}:fullscreen .grid.preview{max-width:1400px}:fullscreen .preview-card{min-height:calc(100vh - 100px)}:fullscreen .media{min-height:calc(100vh - 100px)}:fullscreen .body{max-height:calc(100vh - 100px)}
|
|
44
|
+
@media(max-width:760px){.shell{padding:8px}.head{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.preview-card{display:block;min-height:0}.media{min-height:360px;aspect-ratio:4/5}.body{max-height:none}.image-row{grid-template-columns:72px minmax(0,1fr)}.thumb{width:72px}.overlay{font-size:20px}}
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<main class="shell">
|
|
49
|
+
<header class="head"><div class="actions"><div class="segmented"><button data-mode="preview">预览</button><button data-mode="both">双栏</button><button data-mode="edit">编辑</button></div><button id="fullscreen" class="screen" type="button">全屏</button><button id="save" class="save">保存</button><span id="status" class="status"></span></div></header>
|
|
50
|
+
<section id="grid" class="grid">
|
|
51
|
+
<article id="preview" class="preview-card"><div id="mediaStage" class="media"><div id="media"></div><div id="overlay" class="overlay hidden"></div><div class="arrows"><button id="prev" aria-label="上一张">‹</button><button id="next" aria-label="下一张">›</button></div><div id="dots" class="dots"></div></div><div class="body"><div class="author"><span class="avatar">喵</span><span class="author-name">喵乘舰</span><button class="follow" type="button">关注</button></div><h1 id="previewTitle"></h1><p id="previewBody" class="copy"></p><p id="interaction" class="interaction"></p><div id="tags" class="tags"></div><div class="post-meta">刚刚 · 小红书</div><footer class="social"><span><b>♡</b> 赞</span><span><b>☆</b> 收藏</span><span><b>◯</b> 评论</span></footer></div></article>
|
|
52
|
+
<form id="form" class="form"><label class="field"><span>标题</span><input id="title" maxlength="160"></label><label class="field"><span>正文</span><textarea id="body"></textarea></label><label class="field"><span>互动文案</span><textarea id="interactionInput"></textarea></label><label class="field"><span>话题标签(空格分隔)</span><input id="hashtags"></label><label class="field"><span>审核备注</span><textarea id="review"></textarea></label><div class="images-head"><strong>配图</strong><div><button id="upload" class="upload" type="button">上传图片</button> <button id="add" class="upload" type="button">添加配图</button></div></div><input id="files" class="hidden" type="file" accept="image/*" multiple><div id="images"></div></form>
|
|
53
|
+
</section>
|
|
54
|
+
</main>
|
|
55
|
+
<script>
|
|
56
|
+
const initial=${serialize(artifact)};const apiUrl=${serialize(apiUrl)};let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
|
|
57
|
+
const params=new URLSearchParams(location.search);const requestedTheme=params.get('theme');document.documentElement.dataset.theme=requestedTheme==='dark'?'dark':requestedTheme==='light'?'light':matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
|
58
|
+
function clone(v){return JSON.parse(JSON.stringify(v))}function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function tags(v){return [...new Set(String(v||'').split(/[\\s,,、]+/).filter(Boolean).map(x=>x.startsWith('#')?x:'#'+x))]}
|
|
59
|
+
function field(id,key){$(id).value=key==='hashtags'?(draft[key]||[]).join(' '):draft[key]||'';$(id).addEventListener('input',e=>{draft[key]=key==='hashtags'?tags(e.target.value):e.target.value;renderPreview();scheduleSave()})}
|
|
60
|
+
field('title','title');field('body','body');field('interactionInput','interaction');field('hashtags','hashtags');field('review','review');
|
|
61
|
+
function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(image.url)+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
|
|
62
|
+
function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(im.url)+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(im.url)+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
|
|
63
|
+
$('images').addEventListener('input',e=>{const row=e.target.closest('[data-index]');if(!row||!e.target.dataset.key)return;draft.images[+row.dataset.index][e.target.dataset.key]=e.target.value;renderPreview();scheduleSave()});$('images').addEventListener('click',e=>{const pick=e.target.closest('[data-pick]');if(pick){active=+pick.dataset.pick;renderPreview()}const remove=e.target.closest('[data-remove]');if(remove){draft.images.splice(+remove.dataset.remove,1);renderImages();renderPreview();scheduleSave()}const upload=e.target.closest('[data-upload]');if(upload){$('files').dataset.target=upload.dataset.upload;$('files').click()}});
|
|
64
|
+
$('prev').onclick=()=>{active=(active-1+(draft.images||[]).length)%(draft.images||[]).length;renderPreview()};$('next').onclick=()=>{active=(active+1)%(draft.images||[]).length;renderPreview()};let wheelLock=0;$('mediaStage').addEventListener('wheel',e=>{const images=draft.images||[];if(images.length<2)return;e.preventDefault();const now=Date.now();if(now<wheelLock)return;wheelLock=now+320;const delta=Math.abs(e.deltaY)>=Math.abs(e.deltaX)?e.deltaY:e.deltaX;active=(active+(delta>=0?1:-1)+images.length)%images.length;renderPreview()},{passive:false});$('add').onclick=()=>{(draft.images||(draft.images=[])).push({url:'',caption:'配图 '+((draft.images?.length||0)+1),overlay:'',note:''});renderImages();scheduleSave()};$('upload').onclick=()=>{$('files').dataset.target='';$('files').click()};
|
|
65
|
+
$('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch('/api/uploads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
|
|
66
|
+
function setMode(mode){localStorage.setItem('neoctl.plugin.xhs.mode.v2',mode);document.querySelectorAll('[data-mode]').forEach(b=>b.classList.toggle('active',b.dataset.mode===mode));$('grid').className='grid '+mode;$('preview').classList.toggle('hidden',mode==='edit');$('form').classList.toggle('hidden',mode==='preview');resize()}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
|
|
67
|
+
$('fullscreen').onclick=async()=>{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()};document.addEventListener('fullscreenchange',()=>{$('fullscreen').textContent=document.fullscreenElement?'退出全屏':'全屏'});
|
|
68
|
+
function scheduleSave(){clearTimeout(saveTimer);saveTimer=setTimeout(save,600);setStatus('待保存')}function payload(){return{title:draft.title||'',body:draft.body||'',interaction:draft.interaction||'',hashtags:draft.hashtags||[],images:draft.images||[],review:draft.review||''}}async function save(){clearTimeout(saveTimer);$('save').disabled=true;setStatus('保存中…');try{const res=await fetch(apiUrl,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:draft.title,payload:payload()})});const body=await res.json();if(!res.ok||body.error)throw new Error(body.error||'保存失败');draft=clone(body.artifact.payload);setStatus('已保存');renderPreview();renderImages()}catch(err){setStatus(err.message||String(err),true)}finally{$('save').disabled=false;resize()}}$('save').onclick=save;$('form').onsubmit=e=>e.preventDefault();function setStatus(v,error=false){$('status').textContent=v;$('status').style.color=error?'#ef4444':''}
|
|
69
|
+
function resize(){parent.postMessage({type:'neo-plugin-resource-resize',height:Math.min(1400,Math.max(480,document.documentElement.scrollHeight))},location.origin)}new ResizeObserver(resize).observe(document.body);renderPreview();renderImages();setMode(localStorage.getItem('neoctl.plugin.xhs.mode.v2')||'preview');
|
|
70
|
+
</script>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createOpenXhsArtifactEditorTool, createReadXhsArtifactTool, serveXhsArtifact, XhsArtifactRegistry, xhsArtifactPresentation } from './artifacts.mjs';
|
|
3
|
+
import { serveXhsEditorPage } from './editor-page.mjs';
|
|
4
|
+
import { parseXhsArtifactToolOutput } from './xhs-artifact-contract.mjs';
|
|
5
|
+
|
|
6
|
+
export function createPlugin(context) {
|
|
7
|
+
const configuredStorageDir = String(context.env.NEO_XHS_ARTIFACTS_DIR || '').trim();
|
|
8
|
+
const storageDir = configuredStorageDir
|
|
9
|
+
? path.resolve(configuredStorageDir)
|
|
10
|
+
: path.join(context.appDataDir || path.join(context.pluginDir, '.data'), 'xhs-artifacts');
|
|
11
|
+
const registry = new XhsArtifactRegistry({ storageDir });
|
|
12
|
+
return {
|
|
13
|
+
tools: [
|
|
14
|
+
createOpenXhsArtifactEditorTool({ registry }),
|
|
15
|
+
createReadXhsArtifactTool({ registry }),
|
|
16
|
+
],
|
|
17
|
+
presentToolResult({ toolName, output, ok, sessionId }) {
|
|
18
|
+
if (!ok) return undefined;
|
|
19
|
+
if (toolName === 'read_xhs_artifact') {
|
|
20
|
+
return { title: '读取小红书笔记', text: '已读取稿件。', presentationLevel: 'process' };
|
|
21
|
+
}
|
|
22
|
+
if (toolName !== 'open_xhs_artifact_editor') return undefined;
|
|
23
|
+
const artifact = parseXhsArtifactToolOutput(output);
|
|
24
|
+
return artifact ? xhsArtifactPresentation(artifact, sessionId) : undefined;
|
|
25
|
+
},
|
|
26
|
+
async route(req, res, url, helpers) {
|
|
27
|
+
if (!['GET', 'PUT'].includes(req.method || '') || !url.pathname.startsWith('/api/xhs-artifacts/')) return false;
|
|
28
|
+
const suffix = url.pathname.slice('/api/xhs-artifacts/'.length);
|
|
29
|
+
const editorMatch = /^([^/]+)\/editor$/.exec(suffix);
|
|
30
|
+
const id = decodeURIComponent(editorMatch?.[1] || suffix);
|
|
31
|
+
if (editorMatch) {
|
|
32
|
+
if (req.method !== 'GET') {
|
|
33
|
+
helpers.sendJson?.(res, { error: 'method not allowed' }, 405);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const artifact = registry.get(id, url.searchParams.get('sessionId') || undefined);
|
|
37
|
+
if (!artifact) {
|
|
38
|
+
helpers.sendJson?.(res, { error: 'artifact not found' }, 404);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
serveXhsEditorPage(res, artifact, { sessionId: url.searchParams.get('sessionId') || undefined });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
await serveXhsArtifact(registry, req, res, id, helpers.readJsonBody, url.searchParams.get('sessionId') || undefined);
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|