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.
@@ -0,0 +1,125 @@
1
+ export const XHS_PAYLOAD_FIELDS = ['title', 'body', 'interaction', 'hashtags', 'images', 'review'];
2
+ export const XHS_IMAGE_FIELDS = ['url', 'caption', 'overlay', 'note'];
3
+
4
+ export const XHS_ARTIFACT_EDITOR_HINT = `
5
+ 【小红书编辑器输出契约】
6
+ 当你已经产出一篇完整、可发布的小红书笔记时,必须调用 open_xhs_artifact_editor,不能用普通 Markdown、JSON 代码块或自然语言代替工具调用。
7
+
8
+ 工具参数只能是以下结构,字段名、层级和类型必须完全一致:
9
+ {"payload":{"title":"发布标题","body":"仅发布正文","interaction":"可选互动/活动规则,无则为空字符串","hashtags":["#话题1","#话题2"],"images":[{"url":"真实图片 URL、/api/ 路径或绝对本地路径;尚未生成则为空字符串","caption":"画面内容说明","overlay":"图片上实际显示的文字,无则为空字符串","note":"拍摄/生成/排版备注,无则为空字符串"}],"review":"内部审核备注,无则为空字符串"}}
10
+
11
+ 严格边界:
12
+ 1. payload 必须且只能包含 title、body、interaction、hashtags、images、review 六个字段,六个字段都必须提供。
13
+ 2. title 只放最终发布标题;body 只放最终发布正文,不能重复 title,不能包含“标题/正文/配图/标签/审核”等章节标题,不能包含话题标签、图片方案、审核意见、JSON 或 Markdown 围栏。
14
+ 3. hashtags 必须是字符串数组,每项是一个以 # 开头的话题;话题不能再次写入 body。
15
+ 4. images 必须是数组且至少一项。每项必须且只能包含 url、caption、overlay、note 四个字符串字段,不能传字符串、Markdown 图片、旧字段名或嵌套对象。
16
+ 5. 已生成/已上传图片:把图片工具返回的真实 URL 或绝对路径原样放入 images[].url。未生成图片:url 必须为 "",将配图方案写入 caption/note。严禁把提示词、图片描述、文件 id 或 Markdown 图片语法放入 url。
17
+ 6. interaction 和 review 没有内容时传 "",不得省略。不要把工具参数再输出成正文。
18
+ 7. 修改已有编辑器前,先调用 read_xhs_artifact 读取最新内容,保留用户编辑,再用相同 artifact_id 和完整 payload 调用 open_xhs_artifact_editor。
19
+ `.trim();
20
+
21
+ export const XHS_ARTIFACT_INPUT_SCHEMA = {
22
+ type: 'object',
23
+ properties: {
24
+ artifact_id: {
25
+ type: 'string',
26
+ description: '已有编辑器 id。仅修改时提供;修改前必须先调用 read_xhs_artifact。',
27
+ },
28
+ payload: {
29
+ type: 'object',
30
+ description: '完整的小红书编辑器数据。只能使用声明的六个字段,不接受 Markdown、内容块或旧字段名。',
31
+ properties: {
32
+ title: {
33
+ type: 'string',
34
+ description: '最终发布标题。只放标题,不含正文、封面文案备选、话题或其他章节。',
35
+ },
36
+ body: {
37
+ type: 'string',
38
+ description: '最终发布正文。不得重复标题,不得含话题、配图方案、审核意见、JSON、Markdown 围栏或“正文”等章节标题。',
39
+ },
40
+ interaction: {
41
+ type: 'string',
42
+ description: '单独展示的互动文案或活动规则;没有时必须传空字符串。',
43
+ },
44
+ hashtags: {
45
+ type: 'array',
46
+ description: '话题数组。每项是一个以 # 开头的字符串;不得把话题写入正文。',
47
+ items: { type: 'string' },
48
+ },
49
+ images: {
50
+ type: 'array',
51
+ description: '有序配图数组,至少一项。每项只能是 url、caption、overlay、note 四个字符串字段。',
52
+ items: {
53
+ type: 'object',
54
+ properties: {
55
+ url: {
56
+ type: 'string',
57
+ description: '真实 http(s) URL、/api/ URL 或绝对本地图片路径;图片尚未创建时传空字符串。不得放描述或提示词。',
58
+ },
59
+ caption: {
60
+ type: 'string',
61
+ description: '画面内容说明。不是正文,也不是图片 URL。',
62
+ },
63
+ overlay: {
64
+ type: 'string',
65
+ description: '图片上实际显示的文字;没有时传空字符串。',
66
+ },
67
+ note: {
68
+ type: 'string',
69
+ description: '内部拍摄、生成、布局或设计备注;没有时传空字符串。',
70
+ },
71
+ },
72
+ required: XHS_IMAGE_FIELDS,
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ review: {
77
+ type: 'string',
78
+ description: '内部合规/风格审核备注;没有时必须传空字符串,绝不能写入正文。',
79
+ },
80
+ },
81
+ required: XHS_PAYLOAD_FIELDS,
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ required: ['payload'],
86
+ additionalProperties: false,
87
+ };
88
+
89
+ export function parseXhsArtifactToolOutput(value) {
90
+ const parsed = typeof value === 'string' ? parseFirstJsonObject(value) : value;
91
+ const artifact = parsed?.artifact || parsed?.output?.artifact || parsed?.result?.artifact;
92
+ return isCompleteXhsArtifact(artifact) ? artifact : null;
93
+ }
94
+
95
+ export function selectNewestXhsArtifact(current, candidate) {
96
+ if (!isCompleteXhsArtifact(candidate)) return isCompleteXhsArtifact(current) ? current : null;
97
+ if (!isCompleteXhsArtifact(current)) return candidate;
98
+ if (String(current.id) !== String(candidate.id)) return candidate;
99
+ const currentUpdatedAt = Number(current.updatedAt || current.createdAt || 0);
100
+ const candidateUpdatedAt = Number(candidate.updatedAt || candidate.createdAt || 0);
101
+ return candidateUpdatedAt > currentUpdatedAt ? candidate : current;
102
+ }
103
+
104
+ function isCompleteXhsArtifact(value) {
105
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !String(value.id || '').trim()) return false;
106
+ const payload = value.payload;
107
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
108
+ if (!XHS_PAYLOAD_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(payload, field))) return false;
109
+ if (typeof payload.title !== 'string' || typeof payload.body !== 'string' || typeof payload.interaction !== 'string' || typeof payload.review !== 'string') return false;
110
+ if (!payload.title.trim() || !payload.body.trim() || !Array.isArray(payload.hashtags) || !payload.hashtags.every((tag) => typeof tag === 'string')) return false;
111
+ if (!Array.isArray(payload.images) || !payload.images.length) return false;
112
+ return payload.images.every((image) => image && typeof image === 'object' && !Array.isArray(image)
113
+ && XHS_IMAGE_FIELDS.every((field) => typeof image[field] === 'string'));
114
+ }
115
+
116
+ function parseFirstJsonObject(text) {
117
+ const raw = String(text || '');
118
+ const start = raw.indexOf('{');
119
+ if (start < 0) return null;
120
+ for (let end = raw.length; end > start; end = raw.lastIndexOf('}', end - 1)) {
121
+ if (end <= start) break;
122
+ try { return JSON.parse(raw.slice(start, end + 1)); } catch {}
123
+ }
124
+ return null;
125
+ }
package/plugins.mjs ADDED
@@ -0,0 +1,111 @@
1
+ function normalizePlugin(plugin) {
2
+ if (!plugin || typeof plugin !== 'object') throw new Error('web plugin resource must be an object');
3
+ const id = String(plugin.id || '').trim();
4
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error(`invalid web plugin id: ${id || '(empty)'}`);
5
+ if (!String(plugin.name || '').trim()) throw new Error(`web plugin ${id} is missing name`);
6
+ if (!String(plugin.version || '').trim()) throw new Error(`web plugin ${id} is missing version`);
7
+ if (plugin.route !== undefined && typeof plugin.route !== 'function') throw new Error(`web plugin ${id} route must be a function`);
8
+ if (plugin.presentToolResult !== undefined && typeof plugin.presentToolResult !== 'function') throw new Error(`web plugin ${id} presentToolResult must be a function`);
9
+ return {
10
+ ...plugin,
11
+ id,
12
+ name: String(plugin.name).trim(),
13
+ version: String(plugin.version).trim(),
14
+ defaultEnabled: plugin.defaultEnabled !== false,
15
+ tools: Array.isArray(plugin.tools) ? plugin.tools : [],
16
+ promptSections: Array.isArray(plugin.promptSections) ? plugin.promptSections : [],
17
+ };
18
+ }
19
+
20
+ export function createWebPluginHost(options = {}) {
21
+ const catalog = (options.plugins || []).map(normalizePlugin).sort((left, right) => left.id.localeCompare(right.id));
22
+ const ids = catalog.map((plugin) => plugin.id);
23
+ if (new Set(ids).size !== ids.length) throw new Error('duplicate web plugin id');
24
+ const enabledIds = resolveEnabledPluginIds(catalog, options.enabled);
25
+ const enabled = catalog.filter((plugin) => enabledIds.has(plugin.id));
26
+ const tools = enabled.flatMap((plugin) => plugin.tools);
27
+ const toolNames = tools.map((tool) => String(tool?.name || '').trim()).filter(Boolean);
28
+ if (new Set(toolNames).size !== toolNames.length) throw new Error('duplicate tool name across enabled web plugins');
29
+
30
+ return {
31
+ ids: enabled.map((plugin) => plugin.id),
32
+ tools,
33
+ promptSections: enabled.flatMap((plugin) => plugin.promptSections),
34
+ runtimePlugins(sessionId) {
35
+ const overrides = options.settings?.sessionOverrides(sessionId) || {};
36
+ return {
37
+ externalPlugins: catalog.map((plugin) => ({
38
+ id: plugin.id,
39
+ name: plugin.name,
40
+ version: plugin.version,
41
+ globallyEnabled: enabledIds.has(plugin.id),
42
+ tools: plugin.tools,
43
+ promptSections: plugin.promptSections,
44
+ presentToolResult: plugin.presentToolResult,
45
+ })),
46
+ sessionPluginOverrides: overrides,
47
+ persistSessionPluginOverrides: (resolvedSessionId, next) => options.settings?.setSessionOverrides(resolvedSessionId, next),
48
+ resolveSessionPluginOverrides: (resolvedSessionId) => options.settings?.sessionOverrides(resolvedSessionId) || {},
49
+ };
50
+ },
51
+ snapshot() {
52
+ const configuredIds = new Set(options.settings?.globalEnabledIds() ?? [...enabledIds]);
53
+ return {
54
+ items: catalog.map((plugin) => ({
55
+ id: plugin.id,
56
+ name: plugin.name,
57
+ version: plugin.version,
58
+ enabled: enabledIds.has(plugin.id),
59
+ configuredEnabled: configuredIds.has(plugin.id),
60
+ tools: plugin.tools.map((tool) => tool.name),
61
+ })),
62
+ restartRequired: true,
63
+ locked: options.locked === true,
64
+ };
65
+ },
66
+ async route(req, res, url, helpers = {}) {
67
+ if (req.method === 'GET' && url.pathname === '/api/plugins') {
68
+ helpers.sendJson?.(res, this.snapshot());
69
+ return true;
70
+ }
71
+ if (req.method === 'POST' && url.pathname === '/api/plugins/global') {
72
+ if (options.locked) {
73
+ helpers.sendJson?.(res, { errorCode: 'PLUGINS_LOCKED', error: 'plugins are locked by NEO_WEB_PLUGINS' }, 409);
74
+ return true;
75
+ }
76
+ const body = await helpers.readJsonBody?.(req);
77
+ const requested = Array.isArray(body?.enabledIds) ? body.enabledIds.map(String) : [];
78
+ const unknown = requested.filter((id) => !ids.includes(id));
79
+ if (unknown.length) {
80
+ helpers.sendJson?.(res, { errorCode: 'PLUGIN_INVALID', error: `unknown web plugin: ${unknown.join(', ')}` }, 400);
81
+ return true;
82
+ }
83
+ await options.settings?.setGlobalEnabled(requested);
84
+ helpers.sendJson?.(res, { ok: true, enabledIds: [...new Set(requested)].sort(), restartRequired: true });
85
+ return true;
86
+ }
87
+ for (const plugin of enabled) {
88
+ if (typeof plugin.route === 'function' && await plugin.route(req, res, url, helpers)) return true;
89
+ }
90
+ return false;
91
+ },
92
+ };
93
+ }
94
+
95
+ export function resolveEnabledPluginIds(catalog, configured) {
96
+ const available = new Set(catalog.map((plugin) => plugin.id));
97
+ if (Array.isArray(configured)) {
98
+ const requested = configured.map(String);
99
+ const unknown = requested.filter((id) => !available.has(id));
100
+ if (unknown.length) throw new Error(`unknown web plugin: ${unknown.join(', ')}`);
101
+ return new Set(requested);
102
+ }
103
+ const raw = configured === undefined || configured === null ? '' : String(configured).trim();
104
+ if (!raw) return new Set(catalog.filter((plugin) => plugin.defaultEnabled !== false).map((plugin) => plugin.id));
105
+ if (raw.toLowerCase() === 'none') return new Set();
106
+ if (raw.toLowerCase() === 'all') return available;
107
+ const requested = raw.split(',').map((id) => id.trim()).filter(Boolean);
108
+ const unknown = requested.filter((id) => !available.has(id));
109
+ if (unknown.length) throw new Error(`unknown web plugin: ${unknown.join(', ')}`);
110
+ return new Set(requested);
111
+ }
@@ -0,0 +1,152 @@
1
+ import { WebRuntimeRouter } from './core-runtime.mjs';
2
+
3
+ const INSTALL_KEY = Symbol.for('neoctl-web.runtime-router-session-hubs');
4
+ const routerTimers = new WeakMap();
5
+ const routerAccessTimes = new WeakMap();
6
+
7
+ export function installRuntimeRouterIdleCleanup(options = {}) {
8
+ const prototype = WebRuntimeRouter.prototype;
9
+ if (prototype[INSTALL_KEY]) return;
10
+ prototype[INSTALL_KEY] = true;
11
+
12
+ const idleMs = Math.max(60_000, positiveNumber(options.idleMs || process.env.NEO_RUNTIME_IDLE_MS, 15 * 60_000));
13
+ const maxSessions = Math.max(1, positiveNumber(options.maxSessions || process.env.NEO_RUNTIME_MAX_SESSIONS, 64));
14
+ const originalGet = prototype.get;
15
+
16
+ prototype.get = function getSharedSessionRuntime(scope = {}) {
17
+ const tabId = normalizeScopeValue(scope.tabId);
18
+ const sessionId = normalizeScopeValue(scope.sessionId);
19
+ const key = sessionId ? `session:${sessionId}` : tabId ? `tab:${tabId}` : '__default__';
20
+ let result = this.repls?.get(key);
21
+
22
+ if (!result && sessionId && tabId) {
23
+ const tabKey = `tab:${tabId}`;
24
+ const bootstrap = this.repls?.get(tabKey);
25
+ if (bootstrap) {
26
+ result = promoteBootstrapRuntime(this, originalGet, bootstrap, tabKey, key, sessionId);
27
+ this.repls.set(key, result);
28
+ }
29
+ }
30
+
31
+ if (!result) {
32
+ result = originalGet.call(this, sessionId ? { sessionId } : tabId ? { tabId } : {});
33
+ }
34
+
35
+ touchRuntime(this, key);
36
+ scheduleCleanup(this, key, result, idleMs);
37
+ void enforceSessionLimit(this, maxSessions);
38
+ return result;
39
+ };
40
+ }
41
+
42
+ function promoteBootstrapRuntime(router, originalGet, bootstrap, tabKey, sessionKey, sessionId) {
43
+ let promoted;
44
+ promoted = bootstrap.then(async (repl) => {
45
+ if (runtimeSessionId(repl) === sessionId) {
46
+ if (router.repls?.get(tabKey) === bootstrap) router.repls.delete(tabKey);
47
+ clearRuntimeTimer(router, tabKey);
48
+ clearRuntimeAccessTime(router, tabKey);
49
+ return repl;
50
+ }
51
+
52
+ if (router.repls?.get(sessionKey) === promoted) router.repls.delete(sessionKey);
53
+ return originalGet.call(router, { sessionId });
54
+ });
55
+ return promoted;
56
+ }
57
+
58
+ function scheduleCleanup(router, key, replPromise, idleMs) {
59
+ let timers = routerTimers.get(router);
60
+ if (!timers) {
61
+ timers = new Map();
62
+ routerTimers.set(router, timers);
63
+ }
64
+ clearTimeout(timers.get(key));
65
+ const timer = setTimeout(async () => {
66
+ timers.delete(key);
67
+ if (router.repls?.get(key) !== replPromise) return;
68
+ let repl;
69
+ try { repl = await replPromise; } catch { return; }
70
+ if (isRuntimeActive(repl)) {
71
+ scheduleCleanup(router, key, replPromise, idleMs);
72
+ return;
73
+ }
74
+ router.repls.delete(key);
75
+ clearRuntimeAccessTime(router, key);
76
+ }, idleMs);
77
+ timer.unref?.();
78
+ timers.set(key, timer);
79
+ }
80
+
81
+ async function enforceSessionLimit(router, maxSessions) {
82
+ const entries = [...(router.repls?.entries() || [])]
83
+ .filter(([key]) => key.startsWith('session:'));
84
+ if (entries.length <= maxSessions) return;
85
+
86
+ const accessTimes = routerAccessTimes.get(router) || new Map();
87
+ entries.sort(([left], [right]) => (accessTimes.get(left) || 0) - (accessTimes.get(right) || 0));
88
+ let overflow = entries.length - maxSessions;
89
+ for (const [key, promise] of entries) {
90
+ if (overflow <= 0) break;
91
+ if (router.repls?.get(key) !== promise) continue;
92
+ let repl;
93
+ try { repl = await promise; } catch { repl = undefined; }
94
+ if (repl && isRuntimeActive(repl)) continue;
95
+ if (router.repls?.get(key) !== promise) continue;
96
+ router.repls.delete(key);
97
+ clearRuntimeTimer(router, key);
98
+ clearRuntimeAccessTime(router, key);
99
+ overflow -= 1;
100
+ }
101
+ }
102
+
103
+ function isRuntimeActive(repl) {
104
+ return Boolean(
105
+ repl?.busy
106
+ || repl?.subscribers?.size > 0
107
+ || repl?.backgroundSessionRuns?.size > 0
108
+ || Number(repl?.backgroundTaskCount || 0) > 0
109
+ );
110
+ }
111
+
112
+ function runtimeSessionId(repl) {
113
+ return String(repl?.runtime?.engine?.snapshot?.().session?.sessionId || '').trim();
114
+ }
115
+
116
+ function touchRuntime(router, key) {
117
+ let accessTimes = routerAccessTimes.get(router);
118
+ if (!accessTimes) {
119
+ accessTimes = new Map();
120
+ routerAccessTimes.set(router, accessTimes);
121
+ }
122
+ accessTimes.set(key, Date.now());
123
+ }
124
+
125
+ function clearRuntimeTimer(router, key) {
126
+ const timers = routerTimers.get(router);
127
+ if (!timers) return;
128
+ clearTimeout(timers.get(key));
129
+ timers.delete(key);
130
+ }
131
+
132
+ function clearRuntimeAccessTime(router, key) {
133
+ routerAccessTimes.get(router)?.delete(key);
134
+ }
135
+
136
+ function normalizeScopeValue(value) {
137
+ const normalized = String(value || '').trim();
138
+ return normalized || undefined;
139
+ }
140
+
141
+ function positiveNumber(value, fallback) {
142
+ const parsed = Number(value);
143
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
144
+ }
145
+
146
+ export function runtimeScopeKey(scope = {}) {
147
+ const sessionId = normalizeScopeValue(scope.sessionId);
148
+ if (sessionId) return `session:${sessionId}`;
149
+ const tabId = normalizeScopeValue(scope.tabId);
150
+ if (tabId) return `tab:${tabId}`;
151
+ return '__default__';
152
+ }