neoctl-web 0.1.0 → 0.1.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.
@@ -1,73 +1,73 @@
1
- function escapeHtml(value) {
2
- return String(value ?? '')
3
- .replace(/&/g, '&')
4
- .replace(/</g, '&lt;')
5
- .replace(/>/g, '&gt;')
6
- .replace(/"/g, '&quot;')
7
- .replace(/'/g, '&#39;');
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
- }
1
+ function escapeHtml(value) {
2
+ return String(value ?? '')
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
+ }
@@ -1,48 +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
- }
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
+ }
@@ -1,9 +1,9 @@
1
- {
2
- "protocol": "neo-plugin/v1",
3
- "id": "xhs-artifact",
4
- "name": "小红书编辑器",
5
- "version": "1.0.0",
6
- "entry": "index.mjs",
7
- "defaultEnabled": true,
8
- "description": "提供小红书稿件编辑器工具、持久化和 HTTP 访问能力。"
9
- }
1
+ {
2
+ "protocol": "neo-plugin/v1",
3
+ "id": "xhs-artifact",
4
+ "name": "小红书编辑器",
5
+ "version": "1.0.0",
6
+ "entry": "index.mjs",
7
+ "defaultEnabled": true,
8
+ "description": "提供小红书稿件编辑器工具、持久化和 HTTP 访问能力。"
9
+ }
package/plugins.mjs CHANGED
@@ -1,111 +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
- }
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
+ }