@numa-tech/numa 1.11.10

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.
Files changed (99) hide show
  1. package/README.md +602 -0
  2. package/THIRD_PARTY_NOTICES.md +13 -0
  3. package/assets/codex-shortcut-icon.icns +0 -0
  4. package/assets/codex-shortcut-icon.ico +0 -0
  5. package/dist/app-config.d.ts +26 -0
  6. package/dist/app-config.js +135 -0
  7. package/dist/app-config.js.map +1 -0
  8. package/dist/backend.d.ts +16 -0
  9. package/dist/backend.js +106 -0
  10. package/dist/backend.js.map +1 -0
  11. package/dist/branding.d.ts +7 -0
  12. package/dist/branding.js +13 -0
  13. package/dist/branding.js.map +1 -0
  14. package/dist/chatgpt-console.d.ts +53 -0
  15. package/dist/chatgpt-console.js +105 -0
  16. package/dist/chatgpt-console.js.map +1 -0
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +451 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/codex-auth-agent.d.ts +60 -0
  21. package/dist/codex-auth-agent.js +331 -0
  22. package/dist/codex-auth-agent.js.map +1 -0
  23. package/dist/codex-commands.d.ts +2 -0
  24. package/dist/codex-commands.js +528 -0
  25. package/dist/codex-commands.js.map +1 -0
  26. package/dist/codex-legacy-cleanup.d.ts +5 -0
  27. package/dist/codex-legacy-cleanup.js +77 -0
  28. package/dist/codex-legacy-cleanup.js.map +1 -0
  29. package/dist/codex-process.d.ts +13 -0
  30. package/dist/codex-process.js +193 -0
  31. package/dist/codex-process.js.map +1 -0
  32. package/dist/codex-protocol.d.ts +352 -0
  33. package/dist/codex-protocol.js +116 -0
  34. package/dist/codex-protocol.js.map +1 -0
  35. package/dist/codex-quota-cache.d.ts +20 -0
  36. package/dist/codex-quota-cache.js +82 -0
  37. package/dist/codex-quota-cache.js.map +1 -0
  38. package/dist/codex-selection.d.ts +18 -0
  39. package/dist/codex-selection.js +47 -0
  40. package/dist/codex-selection.js.map +1 -0
  41. package/dist/codex-service-client.d.ts +11 -0
  42. package/dist/codex-service-client.js +91 -0
  43. package/dist/codex-service-client.js.map +1 -0
  44. package/dist/codex-shortcuts.d.ts +43 -0
  45. package/dist/codex-shortcuts.js +429 -0
  46. package/dist/codex-shortcuts.js.map +1 -0
  47. package/dist/codex-tui.d.ts +15 -0
  48. package/dist/codex-tui.js +235 -0
  49. package/dist/codex-tui.js.map +1 -0
  50. package/dist/command-catalog.d.ts +9 -0
  51. package/dist/command-catalog.js +206 -0
  52. package/dist/command-catalog.js.map +1 -0
  53. package/dist/config-page.d.ts +2 -0
  54. package/dist/config-page.js +51 -0
  55. package/dist/config-page.js.map +1 -0
  56. package/dist/config.d.ts +22 -0
  57. package/dist/config.js +99 -0
  58. package/dist/config.js.map +1 -0
  59. package/dist/identity.d.ts +23 -0
  60. package/dist/identity.js +78 -0
  61. package/dist/identity.js.map +1 -0
  62. package/dist/media/client.d.ts +79 -0
  63. package/dist/media/client.js +175 -0
  64. package/dist/media/client.js.map +1 -0
  65. package/dist/media/commands.d.ts +2 -0
  66. package/dist/media/commands.js +193 -0
  67. package/dist/media/commands.js.map +1 -0
  68. package/dist/media/errors.d.ts +8 -0
  69. package/dist/media/errors.js +37 -0
  70. package/dist/media/errors.js.map +1 -0
  71. package/dist/media/schemas.d.ts +62 -0
  72. package/dist/media/schemas.js +71 -0
  73. package/dist/media/schemas.js.map +1 -0
  74. package/dist/media/transfers.d.ts +38 -0
  75. package/dist/media/transfers.js +169 -0
  76. package/dist/media/transfers.js.map +1 -0
  77. package/dist/oauth.d.ts +30 -0
  78. package/dist/oauth.js +295 -0
  79. package/dist/oauth.js.map +1 -0
  80. package/dist/private-file.d.ts +3 -0
  81. package/dist/private-file.js +51 -0
  82. package/dist/private-file.js.map +1 -0
  83. package/dist/server.d.ts +2 -0
  84. package/dist/server.js +197 -0
  85. package/dist/server.js.map +1 -0
  86. package/dist/types.d.ts +40 -0
  87. package/dist/types.js +2 -0
  88. package/dist/types.js.map +1 -0
  89. package/dist/web-console-page.d.ts +3 -0
  90. package/dist/web-console-page.js +59 -0
  91. package/dist/web-console-page.js.map +1 -0
  92. package/dist/web-console.d.ts +22 -0
  93. package/dist/web-console.js +335 -0
  94. package/dist/web-console.js.map +1 -0
  95. package/examples/claude-desktop-installed-binary.json +11 -0
  96. package/examples/claude-desktop-npx.json +12 -0
  97. package/examples/codex-installed-binary.json +11 -0
  98. package/examples/codex-npx.json +12 -0
  99. package/package.json +67 -0
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ export declare const CONSOLE_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Numa CLI \u63A7\u5236\u53F0</title>\n <link rel=\"stylesheet\" href=\"/assets/console.css\">\n</head>\n<body>\n <div id=\"app\" class=\"boot\"><div class=\"boot-mark\">N</div><p>\u6B63\u5728\u8FDE\u63A5\u672C\u5730\u63A7\u5236\u53F0\u2026</p></div>\n <script type=\"module\" src=\"/assets/console.js\"></script>\n</body>\n</html>";
2
+ export declare const CONSOLE_CSS = "\n:root{color-scheme:light;--ink:#0b1728;--navy:#07182b;--navy-2:#102943;--cyan:#0eb7bf;--cyan-soft:#dff7f6;--mist:#eef4f8;--paper:#fbfdff;--line:#d7e2ea;--muted:#64778c;--amber:#e99a32;--red:#d94b58;--green:#198a68;--shadow:0 18px 50px rgba(12,38,62,.1)}\n*{box-sizing:border-box}html,body,#app{min-height:100%}body{min-height:100vh;margin:0;background:var(--mist);color:var(--ink);font-family:\"Avenir Next\",\"Segoe UI\",system-ui,sans-serif}button,input,select,textarea{font:inherit}.mono,code,pre{font-family:\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace}.boot{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:14px;color:var(--muted)}.boot-mark,.brand-mark{display:grid;place-items:center;background:var(--cyan);color:#00262a;font-weight:900}.boot-mark{width:50px;height:50px;border-radius:15px;box-shadow:0 0 0 8px rgba(14,183,191,.12)}\n.shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.rail{position:sticky;top:0;height:100vh;padding:24px 18px;background:var(--navy);color:#d7e6f3;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;padding:8px 8px 26px}.brand-mark{width:38px;height:38px;border-radius:12px}.brand strong{display:block;letter-spacing:.04em}.brand small{display:block;margin-top:4px;color:#708aa3;font-size:10px;letter-spacing:.18em;text-transform:uppercase}.nav{display:grid;gap:7px}.nav button{border:0;background:transparent;color:#8da4b8;border-radius:11px;text-align:left;padding:11px 13px;cursor:pointer;display:flex;justify-content:space-between}.nav button:hover,.nav button.active{background:var(--navy-2);color:white}.nav button.active{box-shadow:inset 3px 0 var(--cyan)}.rail-foot{margin-top:auto;border:1px solid #203b55;border-radius:14px;padding:14px}.rail-foot .pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 12px var(--cyan);margin-right:7px}.rail-foot p{margin:7px 0 0;color:#7791a8;font-size:11px;line-height:1.5}\n.main{min-width:0;min-height:100vh;display:flex;flex-direction:column}.topbar{height:76px;padding:0 34px;background:rgba(251,253,255,.94);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:3;backdrop-filter:blur(12px)}.topbar h1{font-size:17px;margin:0}.eyebrow{font:700 10px/1.2 \"SFMono-Regular\",monospace;text-transform:uppercase;letter-spacing:.16em;color:var(--cyan)}.user-chip{display:flex;align-items:center;gap:10px}.avatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:var(--cyan-soft);color:#087d84;font-weight:800}.user-chip small{color:var(--muted)}.content{width:100%;max-width:1420px;flex:1;margin:0 auto;padding:30px 34px 56px}.hero{background:var(--navy);color:white;border-radius:22px;padding:28px 30px;display:grid;grid-template-columns:1fr auto;gap:24px;position:relative;overflow:hidden;box-shadow:var(--shadow)}.hero:after{content:\"\";position:absolute;right:-45px;top:-90px;width:320px;height:240px;border:34px solid rgba(14,183,191,.09);transform:rotate(-18deg);border-radius:48px}.hero h2{font-size:32px;line-height:1.08;margin:10px 0}.hero p{max-width:720px;color:#91aac0;margin:0;line-height:1.6}.session-track{align-self:center;display:flex;gap:0;position:relative;z-index:1}.track-node{min-width:112px;border:1px solid #2a4661;padding:13px 15px;background:#0b1e33}.track-node:first-child{border-radius:13px 0 0 13px}.track-node:last-child{border-radius:0 13px 13px 0}.track-node span{display:block;color:#7690a7;font-size:9px;text-transform:uppercase;letter-spacing:.12em}.track-node strong{display:block;margin-top:6px;font-size:13px}.track-node.live strong{color:#58e0df}\n.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:18px;margin-top:20px}.card{grid-column:span 4;background:var(--paper);border:1px solid var(--line);border-radius:17px;padding:20px;box-shadow:0 10px 30px rgba(25,54,79,.05)}.card.wide{grid-column:span 8}.card.full{grid-column:1/-1}.card h3{margin:0;font-size:15px}.card p{color:var(--muted);font-size:12px;line-height:1.6}.metric{font-size:28px;font-weight:750;margin-top:16px}.metric small{font-size:11px;color:var(--muted);font-weight:500}.tag{display:inline-flex;border-radius:999px;padding:5px 8px;font:700 9px/1 \"SFMono-Regular\",monospace;text-transform:uppercase;letter-spacing:.05em;background:var(--cyan-soft);color:#087d84}.tag.public{background:#edf1f5;color:#5d7082}.tag.role{background:#fff0d9;color:#9c5c09}.actions{display:flex;gap:8px;flex-wrap:wrap}.button{border:1px solid var(--line);border-radius:10px;background:white;color:var(--ink);padding:9px 12px;cursor:pointer;font-weight:700;font-size:12px}.button:hover{border-color:#9ab6c9;background:#f7fbfd}.button.primary{background:#0b949b;border-color:#0b949b;color:white}.button.danger{color:var(--red);border-color:#f0bec4}.button:disabled{opacity:.45;cursor:not-allowed}.field{display:grid;gap:6px}.field label{font-size:11px;color:var(--muted);font-weight:700}.field input,.field select,.field textarea{width:100%;border:1px solid var(--line);border-radius:10px;background:white;padding:10px 11px;color:var(--ink);outline:none}.field input:focus,.field textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(14,183,191,.12)}.field textarea{min-height:110px;resize:vertical}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-grid .span-2{grid-column:1/-1}.command-toolbar{display:flex;gap:10px;margin-bottom:15px}.grow{flex:1}.mt-10{margin-top:10px}.mt-12{margin-top:12px}.mt-14{margin-top:14px}.command-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.command{border:1px solid var(--line);border-radius:13px;padding:14px;background:white}.command-head{display:flex;gap:8px;align-items:center;justify-content:space-between}.command code{font-size:11px;color:#0b6f76}.command p{margin:9px 0}.command small{display:block;color:#8a9bab;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.output{background:#071522;color:#b8d1e2;border-radius:13px;padding:15px;min-height:170px;max-height:420px;overflow:auto;white-space:pre-wrap;font-size:11px;line-height:1.65}.notice{margin-top:14px;border-radius:11px;padding:11px 13px;font-size:12px}.notice.error{background:#fff0f2;color:#a52c3b;border:1px solid #f1c4ca}.notice.success{background:#e8f7f1;color:#176c54;border:1px solid #bce4d5}.login-gate{min-height:calc(100vh - 76px);display:grid;place-content:center;padding:30px}.login-card{width:min(520px,90vw);background:white;border:1px solid var(--line);border-radius:22px;padding:32px;box-shadow:var(--shadow)}.login-card h2{font-size:28px;margin:14px 0 9px}.login-card p{color:var(--muted);line-height:1.65}.hidden{display:none!important}\n.sync-board{display:grid;grid-template-columns:1fr 56px 1fr;align-items:stretch;margin-top:20px}.sync-stage{background:var(--paper);border:1px solid var(--line);border-radius:17px;padding:18px 20px;box-shadow:0 10px 30px rgba(25,54,79,.05)}.sync-stage strong{display:block;margin-top:8px;font-size:18px}.sync-stage p{margin:7px 0 0;color:var(--muted);font-size:11px}.sync-link{display:grid;place-items:center;color:var(--cyan);font-size:22px}.sync-link:before{content:\"\";height:2px;width:100%;background:linear-gradient(90deg,var(--cyan),#bad8dc)}.table-card{margin-top:18px;background:var(--paper);border:1px solid var(--line);border-radius:17px;box-shadow:0 10px 30px rgba(25,54,79,.05);overflow:hidden}.table-head{padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line)}.table-head h3{margin:0}.table-head p{margin:5px 0 0;color:var(--muted);font-size:11px}.account-table-wrap{overflow:auto}.account-table{width:100%;border-collapse:collapse;min-width:920px}.account-table th,.account-table td{text-align:left;padding:14px 16px;border-bottom:1px solid #e7eef3;vertical-align:middle}.account-table th{background:#f5f9fb;color:#6c8094;font-size:10px;text-transform:uppercase;letter-spacing:.08em}.account-table tr:last-child td{border-bottom:0}.account-name strong{display:block;font-size:13px}.account-name small{display:block;margin-top:4px;color:var(--muted);font-size:10px}.quota-stack{min-width:210px;display:grid;gap:8px}.quota-line{display:grid;grid-template-columns:42px 1fr 38px;align-items:center;gap:7px;font-size:10px;color:var(--muted)}progress{width:100%;height:7px;border:0;border-radius:99px;overflow:hidden;background:#e7eef3}progress::-webkit-progress-bar{background:#e7eef3;border-radius:99px}progress::-webkit-progress-value{background:var(--cyan);border-radius:99px}progress::-moz-progress-bar{background:var(--cyan);border-radius:99px}.status-stack{display:flex;gap:6px;flex-wrap:wrap}.tag.success{background:#e8f7f1;color:var(--green)}.tag.failed{background:#fff0f2;color:var(--red)}.tag.waiting{background:#fff0d9;color:#9c5c09}.tag.local{background:#e8f1fa;color:#32658f}.empty-state{padding:48px 20px;text-align:center;color:var(--muted)}.table-meta{color:var(--muted);font-size:10px;line-height:1.55}.spin{display:inline-block;animation:turn 1s linear infinite}@keyframes turn{to{transform:rotate(360deg)}}\n@media(max-width:980px){.shell{grid-template-columns:78px 1fr}.rail{padding:18px 10px}.brand div:last-child,.nav button span:first-child,.rail-foot p,.rail-foot strong{display:none}.brand{justify-content:center}.nav button{justify-content:center}.card,.card.wide{grid-column:span 6}.hero{grid-template-columns:1fr}.session-track{display:none}}\n@media(max-width:680px){.shell{display:block}.rail{height:auto;position:static;flex-direction:row;align-items:center;padding:10px 14px;overflow-x:auto}.brand{padding:0;margin-right:auto;flex:0 0 auto}.nav{display:flex}.nav button{padding:9px;flex:0 0 auto}.rail-foot{display:none}.topbar{padding:0 18px}.content{padding:20px 14px}.hero{padding:23px}.hero h2{font-size:25px}.card,.card.wide{grid-column:1/-1}.form-grid,.command-list{grid-template-columns:1fr}.form-grid .span-2{grid-column:auto}.user-chip div:not(.avatar){display:none}.sync-board{grid-template-columns:1fr}.sync-link{height:32px;transform:rotate(90deg)}.table-head{align-items:flex-start;flex-direction:column}}\n@media(prefers-reduced-motion:no-preference){.card,.hero{animation:arrive .35s ease both}@keyframes arrive{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}}\n";
3
+ export declare const CONSOLE_JS = "\nconst app=document.getElementById('app');let state=null;let csrf='';let active='overview';let output='\u9009\u62E9\u4E00\u6761\u547D\u4EE4\u6216\u8F93\u5165\u53C2\u6570\u540E\u8FD0\u884C\u3002';let chatgptLocal=null;let chatgptRemote=null;let chatgptLoading=false;\nconst esc=(v)=>String(v??'').replace(/[&<>\"']/g,(c)=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]));\nasync function api(path,init={}){const headers={'Accept':'application/json',...(init.body?{'Content-Type':'application/json'}:{}),...(csrf?{'x-numa-console-csrf':csrf}:{})};const response=await fetch(path,{...init,headers:{...headers,...(init.headers||{})},credentials:'same-origin'});const body=await response.json().catch(()=>({error:{message:'\u54CD\u5E94\u4E0D\u662F JSON'}}));if(!response.ok)throw new Error(body.error?.message||body.stderr||body.stdout||('HTTP '+response.status));return body}\nasync function bootstrap(attempt=0){try{state=await api('/api/bootstrap');csrf=state.csrfToken;const callbackState=new URLSearchParams(location.search).get('login');const waitingForSuccess=callbackState==='complete'&&!state.loggedIn&&attempt<40;const waitingForFailure=callbackState==='failed'&&!state.loginError&&attempt<8;if(waitingForSuccess||waitingForFailure){app.innerHTML='<div class=\"boot\"><div class=\"boot-mark\">N</div><p>'+(callbackState==='complete'?'\u767B\u5F55\u6210\u529F\uFF0C\u6B63\u5728\u5EFA\u7ACB\u672C\u673A\u4F1A\u8BDD\u2026':'\u6B63\u5728\u8BFB\u53D6\u767B\u5F55\u5931\u8D25\u4FE1\u606F\u2026')+'</p></div>';setTimeout(()=>bootstrap(attempt+1),250);return}if(callbackState==='complete'&&!state.loggedIn&&!state.loginError)state.loginError='\u767B\u5F55\u51ED\u636E\u4EA4\u6362\u5C1A\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u8BD5\u767B\u5F55\u3002';if(callbackState==='failed'&&!state.loginError)state.loginError='Keycloak \u767B\u5F55\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u8BD5\u3002';if(callbackState)history.replaceState({},'',location.pathname);render()}catch(error){app.innerHTML='<div class=\"login-gate\"><div class=\"login-card\"><span class=\"tag role\">\u8FDE\u63A5\u5931\u8D25</span><h2>\u672C\u5730\u63A7\u5236\u53F0\u4E0D\u53EF\u7528</h2><p>'+esc(error.message)+'</p></div></div>'}}\nfunction navButton(id,label,mark){return '<button data-nav=\"'+id+'\" class=\"'+(active===id?'active':'')+'\"><span>'+label+'</span><span class=\"mono\">'+mark+'</span></button>'}\nfunction shell(content){const user=state.user||{};const label=user.username||user.email||'\u672A\u767B\u5F55';return '<div class=\"shell\"><aside class=\"rail\"><div class=\"brand\"><div class=\"brand-mark\">N</div><div><strong>Numa CLI</strong><small>Local control plane</small></div></div><nav class=\"nav\">'+navButton('overview','\u8FD0\u884C\u6982\u89C8','01')+navButton('chatgpt','ChatGPT \u8D26\u53F7','02')+navButton('config','\u57FA\u7840\u914D\u7F6E','03')+navButton('commands','\u547D\u4EE4\u80FD\u529B','04')+navButton('runner','\u6267\u884C\u5668','05')+'</nav><div class=\"rail-foot\"><strong><span class=\"pulse\"></span>\u4EC5\u76D1\u542C\u672C\u673A</strong><p>Token \u4E0D\u4F1A\u53D1\u9001\u7ED9\u7F51\u9875\u811A\u672C\uFF0C\u4E5F\u4E0D\u4F1A\u51FA\u73B0\u5728\u547D\u4EE4\u8F93\u51FA\u4E2D\u3002</p></div></aside><main class=\"main\"><header class=\"topbar\"><div><div class=\"eyebrow\">Numa / Web console</div><h1>'+sectionTitle()+'</h1></div><div class=\"user-chip\"><div class=\"avatar\">'+esc(label.slice(0,1).toUpperCase())+'</div><div><strong>'+esc(label)+'</strong><br><small>'+esc(state.loggedIn?'Keycloak \u4F1A\u8BDD\u6709\u6548':'\u9700\u8981\u767B\u5F55')+'</small></div></div></header>'+content+'</main></div>'}\nfunction sectionTitle(){return {overview:'\u8FD0\u884C\u6982\u89C8',chatgpt:'ChatGPT \u8D26\u53F7',config:'\u57FA\u7840\u914D\u7F6E',commands:'\u547D\u4EE4\u80FD\u529B',runner:'\u5B89\u5168\u6267\u884C\u5668'}[active]||'\u63A7\u5236\u53F0'}\nfunction loginGate(){const error=state?.loginError?'<div class=\"notice error\">'+esc(state.loginError)+'</div>':'';return '<div class=\"login-gate\"><div class=\"login-card\"><span class=\"tag role\">Keycloak PKCE</span><h2>\u767B\u5F55\u540E\u8FDB\u5165\u672C\u5730\u63A7\u5236\u53F0</h2><p>\u6D4F\u89C8\u5668\u53EA\u8D1F\u8D23\u5B8C\u6210\u8BA4\u8BC1\u548C\u64CD\u4F5C\u672C\u673A API\u3002\u8BBF\u95EE\u4EE4\u724C\u4FDD\u5B58\u5728 CLI \u7684\u79C1\u6709\u7F13\u5B58\u4E2D\uFF0C\u4E0D\u5199\u5165 localStorage\uFF0C\u4E5F\u4E0D\u8FD4\u56DE\u7ED9\u9875\u9762\u3002</p><div class=\"actions\"><button class=\"button primary\" data-action=\"login\">\u4F7F\u7528 Keycloak \u767B\u5F55</button></div><div id=\"notice\">'+error+'</div></div></div>'}\nfunction overview(){const u=state.user||{};const roles=[...(u.realmRoles||[]),...Object.values(u.clientRoles||{}).flat()];return '<div class=\"content\"><section class=\"hero\"><div><div class=\"eyebrow\">Authenticated workstation</div><h2>\u672C\u673A\u4F1A\u8BDD\u5DF2\u63A5\u5165\u7814\u53D1\u63A7\u5236\u9762</h2><p>\u4ECE\u540C\u4E00\u5904\u68C0\u67E5\u8EAB\u4EFD\u3001\u8C03\u6574 CLI \u914D\u7F6E\uFF0C\u5E76\u4EE5\u7ED3\u6784\u5316\u53C2\u6570\u8FD0\u884C\u53D7\u652F\u6301\u547D\u4EE4\u3002\u6240\u6709\u7F51\u7EDC\u8BF7\u6C42\u7EE7\u7EED\u7531 CLI \u6CE8\u5165 Bearer Token\u3002</p></div><div class=\"session-track\"><div class=\"track-node live\"><span>\u8EAB\u4EFD</span><strong>'+esc(u.username||'\u5DF2\u767B\u5F55')+'</strong></div><div class=\"track-node\"><span>API</span><strong>'+esc(state.config.apiBaseUrl?'\u5DF2\u914D\u7F6E':'\u672A\u914D\u7F6E')+'</strong></div><div class=\"track-node\"><span>\u89D2\u8272</span><strong>'+roles.length+'</strong></div></div></section><section class=\"grid\"><article class=\"card\"><div class=\"eyebrow\">Session</div><div class=\"metric\">'+esc(state.expiresAt?new Date(state.expiresAt*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'\u2014')+' <small>\u8BBF\u95EE\u4EE4\u724C\u5230\u671F</small></div><p>\u9700\u8981\u65F6\u7531 CLI \u4F7F\u7528 refresh token \u81EA\u52A8\u7EED\u671F\u3002</p></article><article class=\"card\"><div class=\"eyebrow\">Commands</div><div class=\"metric\">'+state.commands.length+' <small>\u6761\u80FD\u529B\u5951\u7EA6</small></div><p>\u7F51\u9875\u6267\u884C\u5668\u53EA\u63A5\u53D7\u76EE\u5F55\u4E2D\u58F0\u660E\u7684\u547D\u4EE4\u3002</p></article><article class=\"card\"><div class=\"eyebrow\">Endpoint</div><div class=\"metric\">'+(state.config.apiBaseUrl?'READY':'SETUP')+'</div><p>'+esc(state.config.apiBaseUrl||'\u8BF7\u5728\u57FA\u7840\u914D\u7F6E\u4E2D\u586B\u5199\u5E73\u53F0 API \u5730\u5740')+'</p></article><article class=\"card wide\"><h3>\u5F53\u524D\u8EAB\u4EFD</h3><p>'+esc(u.displayName||u.username||'-')+' \u00B7 '+esc(u.email||'-')+'</p><div class=\"actions\">'+roles.slice(0,8).map((r)=>'<span class=\"tag\">'+esc(r)+'</span>').join('')+(roles.length>8?'<span class=\"tag public\">+'+(roles.length-8)+'</span>':'')+'</div></article><article class=\"card\"><h3>\u4F1A\u8BDD\u64CD\u4F5C</h3><p>\u5F3A\u5236\u5237\u65B0\u8EAB\u4EFD\u8D44\u6599\uFF0C\u6216\u6E05\u9664\u672C\u673A Keycloak \u7F13\u5B58\u3002</p><div class=\"actions\"><button class=\"button\" data-action=\"reload\">\u5237\u65B0\u72B6\u6001</button><button class=\"button danger\" data-action=\"logout\">\u9000\u51FA\u767B\u5F55</button></div></article></section></div>'}\nfunction instant(value){if(!value)return null;const number=typeof value==='number'?(value<1000000000000?value*1000:value):Date.parse(value);return Number.isFinite(number)?number:null}\nfunction timeText(value){const at=instant(value);return at?new Date(at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'\u2014'}\nfunction windowName(window,fallback){const minutes=window?.windowMinutes;if(!minutes)return fallback;if(minutes%1440===0)return (minutes/1440)+'d';if(minutes%60===0)return (minutes/60)+'h';return minutes+'m'}\nfunction quotaWindowHasData(window){if(!window)return false;const minutes=window.windowMinutes;const reset=window.resetAt;const hasMinutes=typeof minutes==='number'&&Number.isFinite(minutes)&&minutes>0;const hasReset=(typeof reset==='number'&&Number.isFinite(reset)&&reset>0)||(typeof reset==='string'&&Number.isFinite(Date.parse(reset)));const hasUsage=typeof window.usedPercent==='number'&&Number.isFinite(window.usedPercent)&&window.usedPercent>0;return hasMinutes||hasReset||hasUsage}\nfunction quotaLine(window,fallback){if(!quotaWindowHasData(window)||typeof window.usedPercent!=='number')return '';const remain=Math.max(0,Math.min(100,100-window.usedPercent));return '<div class=\"quota-line\" title=\"\u989D\u5EA6\u91CD\u7F6E\u65F6\u95F4 '+esc(timeText(window.resetAt))+'\"><span>'+esc(windowName(window,fallback))+'</span><progress max=\"100\" value=\"'+remain.toFixed(1)+'\"></progress><strong>'+remain.toFixed(0)+'%</strong></div>'}\nfunction quotaCell(quota){if(!quota)return '<span class=\"table-meta\">\u6682\u65E0\u989D\u5EA6\u91C7\u6837</span>';const lines=quotaLine(quota.primary,'\u4E3B\u7A97\u53E3')+quotaLine(quota.secondary,'\u6B21\u7A97\u53E3');const detail=[quota.planType,quota.model,quota.activeLimit!=null?'limit '+quota.activeLimit:null].filter(Boolean).join(' \u00B7 ');return '<div class=\"quota-stack\">'+(lines||'<span class=\"table-meta\">\u989D\u5EA6\u7A97\u53E3\u6682\u65E0\u6570\u636E</span>')+'<div class=\"table-meta\">'+esc(detail||'\u5DF2\u91C7\u6837')+'<br>\u91C7\u6837 '+esc(timeText(quota.updatedAt))+'</div></div>'}\nfunction newerQuota(localQuota,remoteQuota){if(!localQuota)return remoteQuota;if(!remoteQuota)return localQuota;return (instant(remoteQuota.updatedAt)||0)>=(instant(localQuota.updatedAt)||0)?remoteQuota:localQuota}\nfunction mergedChatgptAccounts(){const local=chatgptLocal?.accounts||[];const remote=chatgptRemote?.accounts||[];const used=new Set();const rows=local.map((item)=>{const index=remote.findIndex((candidate,i)=>!used.has(i)&&item.accountId&&candidate.accountId===item.accountId);const match=index>=0?remote[index]:null;if(index>=0)used.add(index);return {key:item.id,local:item,remote:match,label:item.label,email:item.email,accountId:item.accountId||match?.accountId,enabled:item.enabled,selected:item.selected,quota:newerQuota(item.quota,match?.quota)}});remote.forEach((item,index)=>{if(!used.has(index))rows.push({key:'remote_'+item.id,local:null,remote:item,label:item.label,email:null,accountId:item.accountId,enabled:true,selected:item.isDefault,quota:item.quota})});return rows}\nfunction syncTag(row){if(chatgptRemote?.status==='LOADING')return '<span class=\"tag waiting\">\u6B63\u5728\u540C\u6B65</span>';if(chatgptRemote?.status==='FAILED')return '<span class=\"tag failed\">\u8FDC\u7A0B\u540C\u6B65\u5931\u8D25</span>';if(chatgptRemote?.status==='NOT_CONFIGURED')return '<span class=\"tag waiting\">\u672A\u914D\u7F6E\u5E73\u53F0</span>';if(row.local&&row.remote)return '<span class=\"tag success\">\u5DF2\u540C\u6B65</span>';if(row.local)return '<span class=\"tag local\">\u4EC5\u672C\u5730</span>';return '<span class=\"tag success\">\u5E73\u53F0\u53EF\u7528</span>'}\nfunction remoteStage(){const remote=chatgptRemote;if(!remote||remote.status==='LOADING')return {tag:'<span class=\"tag waiting\"><span class=\"spin\">\u21BB</span>&nbsp;\u8FDE\u63A5\u4E2D</span>',title:'\u6B63\u5728\u8FDE\u63A5\u5E73\u53F0',detail:'\u672C\u5730\u8D26\u53F7\u5DF2\u7ECF\u53EF\u89C1\uFF0C\u6B63\u5728\u8BFB\u53D6\u8FDC\u7A0B\u53EF\u7528\u8D26\u53F7\u3002'};if(remote.status==='SUCCESS')return {tag:'<span class=\"tag success\">\u540C\u6B65\u6210\u529F</span>',title:remote.accounts.length+' \u4E2A\u5E73\u53F0\u8D26\u53F7',detail:'\u5B8C\u6210\u4E8E '+timeText(remote.syncedAt)};if(remote.status==='FAILED')return {tag:'<span class=\"tag failed\">\u8FDC\u7A0B\u540C\u6B65\u5931\u8D25</span>',title:'\u4FDD\u7559\u672C\u5730\u7ED3\u679C',detail:remote.error?.message||'\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u8D26\u53F7\u670D\u52A1'};return {tag:'<span class=\"tag waiting\">\u672A\u914D\u7F6E</span>',title:'\u672A\u914D\u7F6E\u5E73\u53F0\u5730\u5740',detail:'\u53EF\u5728\u57FA\u7840\u914D\u7F6E\u4E2D\u586B\u5199\u5E73\u53F0 API \u5730\u5740\u3002'}}\nfunction accountRows(rows){return rows.map((row)=>'<tr><td><div class=\"account-name\"><strong>'+esc(row.label)+'</strong><small>'+esc(row.email||row.accountId||row.key)+'</small></div></td><td><div class=\"status-stack\">'+(row.enabled?'<span class=\"tag success\">\u53EF\u7528</span>':'<span class=\"tag failed\">\u5DF2\u505C\u7528</span>')+(row.selected?'<span class=\"tag role\">\u5BA2\u6237\u7AEF\u5DF2\u9009</span>':'')+'</div></td><td>'+quotaCell(row.quota)+'</td><td><div class=\"table-meta\">Token '+esc(row.local?'\u672C\u5730\u53EF\u7528':'\u6309\u9700\u4ECE\u5E73\u53F0\u83B7\u53D6')+'<br>\u5230\u671F '+esc(timeText(row.local?.accessExpiresAt||row.remote?.accessExpiresAt))+'</div></td><td><div class=\"status-stack\">'+syncTag(row)+'</div></td></tr>').join('')}\nfunction chatgptPage(){const rows=mergedChatgptAccounts();const remote=remoteStage();const localReady=chatgptLocal?.status==='READY';const localCount=chatgptLocal?.available??0;const failure=chatgptRemote?.status==='FAILED'?'<div class=\"notice error\"><strong>\u8FDC\u7A0B\u540C\u6B65\u5931\u8D25\u3002</strong> '+esc(chatgptRemote.error?.message||'\u5E73\u53F0\u6682\u65F6\u4E0D\u53EF\u7528')+'\uFF1B\u672C\u5730\u8D26\u53F7\u6570\u636E\u4ECD\u53EF\u6B63\u5E38\u67E5\u770B\u3002</div>':'';return '<div class=\"content\"><section class=\"hero\"><div><div class=\"eyebrow\">Local first / Remote second</div><h2>ChatGPT \u8D26\u53F7\u8FD0\u884C\u89C6\u56FE</h2><p>\u5148\u8BFB\u53D6 codex-multi-auth \u672C\u5730\u8D26\u53F7\u4E0E\u989D\u5EA6\u7F13\u5B58\uFF0C\u518D\u8FDE\u63A5\u5E73\u53F0\u8865\u5145\u5F53\u524D\u7528\u6237\u53EF\u7528\u8D26\u53F7\u3002\u9875\u9762\u6C38\u4E0D\u63A5\u6536 access token \u6216 refresh token\u3002</p></div><div class=\"session-track\"><div class=\"track-node live\"><span>\u672C\u5730\u53EF\u7528</span><strong>'+localCount+'</strong></div><div class=\"track-node\"><span>\u6C47\u603B\u8D26\u53F7</span><strong>'+rows.length+'</strong></div><div class=\"track-node\"><span>\u8FDC\u7A0B</span><strong>'+esc(chatgptRemote?.status||'WAITING')+'</strong></div></div></section><section class=\"sync-board\"><article class=\"sync-stage\"><div class=\"eyebrow\">01 / Local pool</div><strong>'+(localReady?localCount+' \u4E2A\u672C\u5730\u53EF\u7528\u8D26\u53F7':'\u6B63\u5728\u8BFB\u53D6\u672C\u5730\u8D26\u53F7')+'</strong><p>'+(localReady?'\u8BFB\u53D6\u5B8C\u6210\u4E8E '+timeText(chatgptLocal.readAt):'\u4F18\u5148\u8BFB\u53D6 codex-multi-auth \u5B89\u5168\u6458\u8981\u3002')+'</p></article><div class=\"sync-link\" aria-label=\"\u7136\u540E\u8FDE\u63A5\u8FDC\u7A0B\"></div><article class=\"sync-stage\"><div class=\"eyebrow\">02 / Platform service</div>'+remote.tag+'<strong>'+esc(remote.title)+'</strong><p>'+esc(remote.detail)+'</p></article></section>'+failure+'<section class=\"table-card\"><header class=\"table-head\"><div><h3>\u53EF\u7528\u8D26\u53F7\u4E0E\u5269\u4F59\u989D\u5EA6</h3><p>\u989D\u5EA6\u4F18\u5148\u5C55\u793A\u672C\u5730\u6216\u670D\u52A1\u5668\u7AEF\u66F4\u65B0\u8F83\u65B0\u7684\u5B89\u5168\u5FEB\u7167\u3002</p></div><button class=\"button\" data-action=\"chatgpt-refresh\" '+(chatgptLoading?'disabled':'')+'>'+(chatgptLoading?'<span class=\"spin\">\u21BB</span> \u540C\u6B65\u4E2D':'\u21BB \u91CD\u65B0\u540C\u6B65')+'</button></header>'+(rows.length?'<div class=\"account-table-wrap\"><table class=\"account-table\"><thead><tr><th>\u8D26\u53F7</th><th>\u53EF\u7528\u72B6\u6001</th><th>\u5269\u4F59 limit</th><th>\u51ED\u636E\u65F6\u6548</th><th>\u540C\u6B65\u72B6\u6001</th></tr></thead><tbody>'+accountRows(rows)+'</tbody></table></div>':'<div class=\"empty-state\">'+(chatgptLoading?'\u6B63\u5728\u8BFB\u53D6\u672C\u5730\u8D26\u53F7\u2026':'\u672A\u53D1\u73B0\u672C\u5730\u6216\u5E73\u53F0\u53EF\u7528\u8D26\u53F7')+'</div>')+'</section></div>'}\nfunction configPage(){const c=state.config;return '<div class=\"content\"><section class=\"grid\"><article class=\"card full\"><div class=\"eyebrow\">Configuration file</div><h3>\u57FA\u7840\u914D\u7F6E</h3><p>\u4E0E <code>numa config --set</code> \u4F7F\u7528\u540C\u4E00\u4EFD\u914D\u7F6E\u548C\u6821\u9A8C\u903B\u8F91\u3002</p><form id=\"config-form\" class=\"form-grid\"><div class=\"field\"><label>\u754C\u9762\u8BED\u8A00</label><select name=\"locale\"><option '+(c.locale==='zh-CN'?'selected':'')+'>zh-CN</option><option '+(c.locale==='en-US'?'selected':'')+'>en-US</option></select></div><div class=\"field\"><label>\u9ED8\u8BA4\u8F93\u51FA</label><select name=\"output\"><option '+(c.output==='text'?'selected':'')+'>text</option><option '+(c.output==='json'?'selected':'')+'>json</option></select></div><div class=\"field span-2\"><label>\u5E73\u53F0 API \u5730\u5740</label><input name=\"apiBaseUrl\" value=\"'+esc(c.apiBaseUrl)+'\" placeholder=\"https://platform.example/api\"></div><div class=\"field span-2\"><label>npm registry</label><input name=\"registryUrl\" value=\"'+esc(c.registryUrl)+'\"></div><label class=\"field span-2\"><span>\u66F4\u65B0\u68C0\u67E5</span><select name=\"checkUpdates\"><option value=\"true\" '+(c.checkUpdates?'selected':'')+'>\u542F\u7528</option><option value=\"false\" '+(!c.checkUpdates?'selected':'')+'>\u5173\u95ED</option></select></label><div class=\"actions span-2\"><button class=\"button primary\" type=\"submit\">\u4FDD\u5B58\u914D\u7F6E</button></div></form><div id=\"notice\"></div></article></section></div>'}\nfunction commandsPage(){return '<div class=\"content\"><section class=\"card full\"><div class=\"command-toolbar\"><div class=\"field grow\"><label>\u8FC7\u6EE4\u547D\u4EE4</label><input id=\"command-search\" placeholder=\"\u4F8B\u5982 codex\u3001media\u3001auth\"></div></div><div id=\"command-list\" class=\"command-list\">'+commandCards('')+'</div></section></div>'}\nfunction commandCards(query){const q=query.toLowerCase();return state.commands.filter((c)=>(c.command+' '+c.summary).toLowerCase().includes(q)).map((c)=>'<article class=\"command\"><div class=\"command-head\"><code>'+esc(c.command)+'</code><span class=\"tag '+esc(c.access)+'\">'+esc(c.access)+'</span></div><p>'+esc(c.summary)+'</p><small>'+esc(c.examples[0]||'')+'</small><div class=\"actions mt-10\"><button class=\"button\" data-load=\"'+esc(JSON.stringify(commandPrefix(c.command)))+'\">\u8F7D\u5165\u6267\u884C\u5668</button></div></article>').join('')||'<p>\u6CA1\u6709\u5339\u914D\u547D\u4EE4\u3002</p>'}\nfunction commandPrefix(command){return command.split(/s+/).filter((part)=>!part.startsWith('[')&&!part.startsWith('<')&&!part.startsWith('--'))}\nfunction runnerPage(){return '<div class=\"content\"><section class=\"grid\"><article class=\"card\"><div class=\"eyebrow\">Structured argv</div><h3>\u5B89\u5168\u6267\u884C\u5668</h3><p>\u4F7F\u7528 JSON \u6570\u7EC4\u4F20\u5165\u53C2\u6570\uFF0C\u4E0D\u7ECF\u8FC7 shell\u3002\u957F\u9A7B\u6216\u4EA4\u4E92\u547D\u4EE4\u9700\u8981\u5728\u7EC8\u7AEF\u8FD0\u884C\u3002</p><form id=\"runner-form\"><div class=\"field\"><label>\u547D\u4EE4\u53C2\u6570</label><textarea id=\"argv\" class=\"mono\">[\"doctor\"]</textarea></div><label class=\"field mt-12\"><span>\u5371\u9669\u64CD\u4F5C\u786E\u8BA4</span><select id=\"confirm\"><option value=\"false\">\u672A\u786E\u8BA4</option><option value=\"true\">\u786E\u8BA4\u6267\u884C\u5220\u9664/\u91CD\u7F6E</option></select></label><div class=\"actions mt-14\"><button class=\"button primary\" type=\"submit\">\u8FD0\u884C\u547D\u4EE4</button></div></form></article><article class=\"card wide\"><div class=\"eyebrow\">Result stream</div><h3>\u7ED3\u6784\u5316\u8F93\u51FA</h3><pre id=\"output\" class=\"output\">'+esc(output)+'</pre></article></section></div>'}\nasync function loadChatgpt(){if(chatgptLoading)return;chatgptLoading=true;chatgptLocal=null;chatgptRemote={status:'LOADING',accounts:[]};render();try{chatgptLocal=await api('/api/chatgpt/local')}catch(error){chatgptLocal={status:'FAILED',accounts:[],available:0,total:0,error:{message:error.message}}}render();try{chatgptRemote=await api('/api/chatgpt/remote')}catch(error){chatgptRemote={status:'FAILED',accounts:[],syncedAt:Date.now(),error:{message:error.message}}}finally{chatgptLoading=false;render()}}\nfunction render(){if(!state.loggedIn){app.innerHTML=loginGate();bind();return}const page=active==='overview'?overview():active==='chatgpt'?chatgptPage():active==='config'?configPage():active==='commands'?commandsPage():runnerPage();app.innerHTML=shell(page);bind()}\nfunction notice(message,type='success'){const target=document.getElementById('notice');if(target)target.innerHTML='<div class=\"notice '+type+'\">'+esc(message)+'</div>'}\nfunction bindCommandLoaders(){document.querySelectorAll('[data-load]').forEach((b)=>b.addEventListener('click',()=>{const argv=JSON.parse(b.dataset.load);active='runner';render();document.getElementById('argv').value=JSON.stringify(argv)}))}\nfunction bind(){document.querySelectorAll('[data-nav]').forEach((b)=>b.addEventListener('click',()=>{active=b.dataset.nav;render();if(active==='chatgpt'&&!chatgptLocal&&!chatgptLoading)void loadChatgpt()}));document.querySelector('[data-action=\"chatgpt-refresh\"]')?.addEventListener('click',()=>void loadChatgpt());document.querySelector('[data-action=\"login\"]')?.addEventListener('click',async()=>{try{notice('\u6B63\u5728\u542F\u52A8\u767B\u5F55\u2026');const result=await api('/api/auth/login',{method:'POST',body:'{}'});location.href=result.authorizationUrl}catch(e){notice(e.message,'error')}});document.querySelector('[data-action=\"reload\"]')?.addEventListener('click',()=>bootstrap());document.querySelector('[data-action=\"logout\"]')?.addEventListener('click',async()=>{if(!confirm('\u6E05\u9664\u672C\u673A Keycloak \u767B\u5F55\u7F13\u5B58\uFF1F'))return;await api('/api/auth/logout',{method:'POST',body:'{}'});location.reload()});document.getElementById('config-form')?.addEventListener('submit',async(e)=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.currentTarget));data.checkUpdates=data.checkUpdates==='true';try{const result=await api('/api/config',{method:'PUT',body:JSON.stringify(data)});state.config=result.config;notice('\u914D\u7F6E\u5DF2\u4FDD\u5B58')}catch(err){notice(err.message,'error')}});document.getElementById('command-search')?.addEventListener('input',(e)=>{document.getElementById('command-list').innerHTML=commandCards(e.target.value);bindCommandLoaders()});bindCommandLoaders();document.getElementById('runner-form')?.addEventListener('submit',async(e)=>{e.preventDefault();try{const argv=JSON.parse(document.getElementById('argv').value);const confirmed=document.getElementById('confirm')?.value==='true';output='\u8FD0\u884C\u4E2D\u2026';render();const result=await api('/api/commands/run',{method:'POST',body:JSON.stringify({argv,confirm:confirmed})});output=result.stdout||result.stderr||JSON.stringify(result,null,2);render()}catch(err){output='\u9519\u8BEF: '+err.message;render()}})}\nbootstrap();\n";
@@ -0,0 +1,59 @@
1
+ export const CONSOLE_HTML = `<!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Numa CLI 控制台</title>
7
+ <link rel="stylesheet" href="/assets/console.css">
8
+ </head>
9
+ <body>
10
+ <div id="app" class="boot"><div class="boot-mark">N</div><p>正在连接本地控制台…</p></div>
11
+ <script type="module" src="/assets/console.js"></script>
12
+ </body>
13
+ </html>`;
14
+ export const CONSOLE_CSS = `
15
+ :root{color-scheme:light;--ink:#0b1728;--navy:#07182b;--navy-2:#102943;--cyan:#0eb7bf;--cyan-soft:#dff7f6;--mist:#eef4f8;--paper:#fbfdff;--line:#d7e2ea;--muted:#64778c;--amber:#e99a32;--red:#d94b58;--green:#198a68;--shadow:0 18px 50px rgba(12,38,62,.1)}
16
+ *{box-sizing:border-box}html,body,#app{min-height:100%}body{min-height:100vh;margin:0;background:var(--mist);color:var(--ink);font-family:"Avenir Next","Segoe UI",system-ui,sans-serif}button,input,select,textarea{font:inherit}.mono,code,pre{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace}.boot{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:14px;color:var(--muted)}.boot-mark,.brand-mark{display:grid;place-items:center;background:var(--cyan);color:#00262a;font-weight:900}.boot-mark{width:50px;height:50px;border-radius:15px;box-shadow:0 0 0 8px rgba(14,183,191,.12)}
17
+ .shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.rail{position:sticky;top:0;height:100vh;padding:24px 18px;background:var(--navy);color:#d7e6f3;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;padding:8px 8px 26px}.brand-mark{width:38px;height:38px;border-radius:12px}.brand strong{display:block;letter-spacing:.04em}.brand small{display:block;margin-top:4px;color:#708aa3;font-size:10px;letter-spacing:.18em;text-transform:uppercase}.nav{display:grid;gap:7px}.nav button{border:0;background:transparent;color:#8da4b8;border-radius:11px;text-align:left;padding:11px 13px;cursor:pointer;display:flex;justify-content:space-between}.nav button:hover,.nav button.active{background:var(--navy-2);color:white}.nav button.active{box-shadow:inset 3px 0 var(--cyan)}.rail-foot{margin-top:auto;border:1px solid #203b55;border-radius:14px;padding:14px}.rail-foot .pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 12px var(--cyan);margin-right:7px}.rail-foot p{margin:7px 0 0;color:#7791a8;font-size:11px;line-height:1.5}
18
+ .main{min-width:0;min-height:100vh;display:flex;flex-direction:column}.topbar{height:76px;padding:0 34px;background:rgba(251,253,255,.94);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:3;backdrop-filter:blur(12px)}.topbar h1{font-size:17px;margin:0}.eyebrow{font:700 10px/1.2 "SFMono-Regular",monospace;text-transform:uppercase;letter-spacing:.16em;color:var(--cyan)}.user-chip{display:flex;align-items:center;gap:10px}.avatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:var(--cyan-soft);color:#087d84;font-weight:800}.user-chip small{color:var(--muted)}.content{width:100%;max-width:1420px;flex:1;margin:0 auto;padding:30px 34px 56px}.hero{background:var(--navy);color:white;border-radius:22px;padding:28px 30px;display:grid;grid-template-columns:1fr auto;gap:24px;position:relative;overflow:hidden;box-shadow:var(--shadow)}.hero:after{content:"";position:absolute;right:-45px;top:-90px;width:320px;height:240px;border:34px solid rgba(14,183,191,.09);transform:rotate(-18deg);border-radius:48px}.hero h2{font-size:32px;line-height:1.08;margin:10px 0}.hero p{max-width:720px;color:#91aac0;margin:0;line-height:1.6}.session-track{align-self:center;display:flex;gap:0;position:relative;z-index:1}.track-node{min-width:112px;border:1px solid #2a4661;padding:13px 15px;background:#0b1e33}.track-node:first-child{border-radius:13px 0 0 13px}.track-node:last-child{border-radius:0 13px 13px 0}.track-node span{display:block;color:#7690a7;font-size:9px;text-transform:uppercase;letter-spacing:.12em}.track-node strong{display:block;margin-top:6px;font-size:13px}.track-node.live strong{color:#58e0df}
19
+ .grid{display:grid;grid-template-columns:repeat(12,1fr);gap:18px;margin-top:20px}.card{grid-column:span 4;background:var(--paper);border:1px solid var(--line);border-radius:17px;padding:20px;box-shadow:0 10px 30px rgba(25,54,79,.05)}.card.wide{grid-column:span 8}.card.full{grid-column:1/-1}.card h3{margin:0;font-size:15px}.card p{color:var(--muted);font-size:12px;line-height:1.6}.metric{font-size:28px;font-weight:750;margin-top:16px}.metric small{font-size:11px;color:var(--muted);font-weight:500}.tag{display:inline-flex;border-radius:999px;padding:5px 8px;font:700 9px/1 "SFMono-Regular",monospace;text-transform:uppercase;letter-spacing:.05em;background:var(--cyan-soft);color:#087d84}.tag.public{background:#edf1f5;color:#5d7082}.tag.role{background:#fff0d9;color:#9c5c09}.actions{display:flex;gap:8px;flex-wrap:wrap}.button{border:1px solid var(--line);border-radius:10px;background:white;color:var(--ink);padding:9px 12px;cursor:pointer;font-weight:700;font-size:12px}.button:hover{border-color:#9ab6c9;background:#f7fbfd}.button.primary{background:#0b949b;border-color:#0b949b;color:white}.button.danger{color:var(--red);border-color:#f0bec4}.button:disabled{opacity:.45;cursor:not-allowed}.field{display:grid;gap:6px}.field label{font-size:11px;color:var(--muted);font-weight:700}.field input,.field select,.field textarea{width:100%;border:1px solid var(--line);border-radius:10px;background:white;padding:10px 11px;color:var(--ink);outline:none}.field input:focus,.field textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(14,183,191,.12)}.field textarea{min-height:110px;resize:vertical}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-grid .span-2{grid-column:1/-1}.command-toolbar{display:flex;gap:10px;margin-bottom:15px}.grow{flex:1}.mt-10{margin-top:10px}.mt-12{margin-top:12px}.mt-14{margin-top:14px}.command-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.command{border:1px solid var(--line);border-radius:13px;padding:14px;background:white}.command-head{display:flex;gap:8px;align-items:center;justify-content:space-between}.command code{font-size:11px;color:#0b6f76}.command p{margin:9px 0}.command small{display:block;color:#8a9bab;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.output{background:#071522;color:#b8d1e2;border-radius:13px;padding:15px;min-height:170px;max-height:420px;overflow:auto;white-space:pre-wrap;font-size:11px;line-height:1.65}.notice{margin-top:14px;border-radius:11px;padding:11px 13px;font-size:12px}.notice.error{background:#fff0f2;color:#a52c3b;border:1px solid #f1c4ca}.notice.success{background:#e8f7f1;color:#176c54;border:1px solid #bce4d5}.login-gate{min-height:calc(100vh - 76px);display:grid;place-content:center;padding:30px}.login-card{width:min(520px,90vw);background:white;border:1px solid var(--line);border-radius:22px;padding:32px;box-shadow:var(--shadow)}.login-card h2{font-size:28px;margin:14px 0 9px}.login-card p{color:var(--muted);line-height:1.65}.hidden{display:none!important}
20
+ .sync-board{display:grid;grid-template-columns:1fr 56px 1fr;align-items:stretch;margin-top:20px}.sync-stage{background:var(--paper);border:1px solid var(--line);border-radius:17px;padding:18px 20px;box-shadow:0 10px 30px rgba(25,54,79,.05)}.sync-stage strong{display:block;margin-top:8px;font-size:18px}.sync-stage p{margin:7px 0 0;color:var(--muted);font-size:11px}.sync-link{display:grid;place-items:center;color:var(--cyan);font-size:22px}.sync-link:before{content:"";height:2px;width:100%;background:linear-gradient(90deg,var(--cyan),#bad8dc)}.table-card{margin-top:18px;background:var(--paper);border:1px solid var(--line);border-radius:17px;box-shadow:0 10px 30px rgba(25,54,79,.05);overflow:hidden}.table-head{padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line)}.table-head h3{margin:0}.table-head p{margin:5px 0 0;color:var(--muted);font-size:11px}.account-table-wrap{overflow:auto}.account-table{width:100%;border-collapse:collapse;min-width:920px}.account-table th,.account-table td{text-align:left;padding:14px 16px;border-bottom:1px solid #e7eef3;vertical-align:middle}.account-table th{background:#f5f9fb;color:#6c8094;font-size:10px;text-transform:uppercase;letter-spacing:.08em}.account-table tr:last-child td{border-bottom:0}.account-name strong{display:block;font-size:13px}.account-name small{display:block;margin-top:4px;color:var(--muted);font-size:10px}.quota-stack{min-width:210px;display:grid;gap:8px}.quota-line{display:grid;grid-template-columns:42px 1fr 38px;align-items:center;gap:7px;font-size:10px;color:var(--muted)}progress{width:100%;height:7px;border:0;border-radius:99px;overflow:hidden;background:#e7eef3}progress::-webkit-progress-bar{background:#e7eef3;border-radius:99px}progress::-webkit-progress-value{background:var(--cyan);border-radius:99px}progress::-moz-progress-bar{background:var(--cyan);border-radius:99px}.status-stack{display:flex;gap:6px;flex-wrap:wrap}.tag.success{background:#e8f7f1;color:var(--green)}.tag.failed{background:#fff0f2;color:var(--red)}.tag.waiting{background:#fff0d9;color:#9c5c09}.tag.local{background:#e8f1fa;color:#32658f}.empty-state{padding:48px 20px;text-align:center;color:var(--muted)}.table-meta{color:var(--muted);font-size:10px;line-height:1.55}.spin{display:inline-block;animation:turn 1s linear infinite}@keyframes turn{to{transform:rotate(360deg)}}
21
+ @media(max-width:980px){.shell{grid-template-columns:78px 1fr}.rail{padding:18px 10px}.brand div:last-child,.nav button span:first-child,.rail-foot p,.rail-foot strong{display:none}.brand{justify-content:center}.nav button{justify-content:center}.card,.card.wide{grid-column:span 6}.hero{grid-template-columns:1fr}.session-track{display:none}}
22
+ @media(max-width:680px){.shell{display:block}.rail{height:auto;position:static;flex-direction:row;align-items:center;padding:10px 14px;overflow-x:auto}.brand{padding:0;margin-right:auto;flex:0 0 auto}.nav{display:flex}.nav button{padding:9px;flex:0 0 auto}.rail-foot{display:none}.topbar{padding:0 18px}.content{padding:20px 14px}.hero{padding:23px}.hero h2{font-size:25px}.card,.card.wide{grid-column:1/-1}.form-grid,.command-list{grid-template-columns:1fr}.form-grid .span-2{grid-column:auto}.user-chip div:not(.avatar){display:none}.sync-board{grid-template-columns:1fr}.sync-link{height:32px;transform:rotate(90deg)}.table-head{align-items:flex-start;flex-direction:column}}
23
+ @media(prefers-reduced-motion:no-preference){.card,.hero{animation:arrive .35s ease both}@keyframes arrive{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}}
24
+ `;
25
+ export const CONSOLE_JS = `
26
+ const app=document.getElementById('app');let state=null;let csrf='';let active='overview';let output='选择一条命令或输入参数后运行。';let chatgptLocal=null;let chatgptRemote=null;let chatgptLoading=false;
27
+ const esc=(v)=>String(v??'').replace(/[&<>"']/g,(c)=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
28
+ async function api(path,init={}){const headers={'Accept':'application/json',...(init.body?{'Content-Type':'application/json'}:{}),...(csrf?{'x-numa-console-csrf':csrf}:{})};const response=await fetch(path,{...init,headers:{...headers,...(init.headers||{})},credentials:'same-origin'});const body=await response.json().catch(()=>({error:{message:'响应不是 JSON'}}));if(!response.ok)throw new Error(body.error?.message||body.stderr||body.stdout||('HTTP '+response.status));return body}
29
+ async function bootstrap(attempt=0){try{state=await api('/api/bootstrap');csrf=state.csrfToken;const callbackState=new URLSearchParams(location.search).get('login');const waitingForSuccess=callbackState==='complete'&&!state.loggedIn&&attempt<40;const waitingForFailure=callbackState==='failed'&&!state.loginError&&attempt<8;if(waitingForSuccess||waitingForFailure){app.innerHTML='<div class="boot"><div class="boot-mark">N</div><p>'+(callbackState==='complete'?'登录成功,正在建立本机会话…':'正在读取登录失败信息…')+'</p></div>';setTimeout(()=>bootstrap(attempt+1),250);return}if(callbackState==='complete'&&!state.loggedIn&&!state.loginError)state.loginError='登录凭据交换尚未完成,请重试登录。';if(callbackState==='failed'&&!state.loginError)state.loginError='Keycloak 登录未完成,请重试。';if(callbackState)history.replaceState({},'',location.pathname);render()}catch(error){app.innerHTML='<div class="login-gate"><div class="login-card"><span class="tag role">连接失败</span><h2>本地控制台不可用</h2><p>'+esc(error.message)+'</p></div></div>'}}
30
+ function navButton(id,label,mark){return '<button data-nav="'+id+'" class="'+(active===id?'active':'')+'"><span>'+label+'</span><span class="mono">'+mark+'</span></button>'}
31
+ function shell(content){const user=state.user||{};const label=user.username||user.email||'未登录';return '<div class="shell"><aside class="rail"><div class="brand"><div class="brand-mark">N</div><div><strong>Numa CLI</strong><small>Local control plane</small></div></div><nav class="nav">'+navButton('overview','运行概览','01')+navButton('chatgpt','ChatGPT 账号','02')+navButton('config','基础配置','03')+navButton('commands','命令能力','04')+navButton('runner','执行器','05')+'</nav><div class="rail-foot"><strong><span class="pulse"></span>仅监听本机</strong><p>Token 不会发送给网页脚本,也不会出现在命令输出中。</p></div></aside><main class="main"><header class="topbar"><div><div class="eyebrow">Numa / Web console</div><h1>'+sectionTitle()+'</h1></div><div class="user-chip"><div class="avatar">'+esc(label.slice(0,1).toUpperCase())+'</div><div><strong>'+esc(label)+'</strong><br><small>'+esc(state.loggedIn?'Keycloak 会话有效':'需要登录')+'</small></div></div></header>'+content+'</main></div>'}
32
+ function sectionTitle(){return {overview:'运行概览',chatgpt:'ChatGPT 账号',config:'基础配置',commands:'命令能力',runner:'安全执行器'}[active]||'控制台'}
33
+ function loginGate(){const error=state?.loginError?'<div class="notice error">'+esc(state.loginError)+'</div>':'';return '<div class="login-gate"><div class="login-card"><span class="tag role">Keycloak PKCE</span><h2>登录后进入本地控制台</h2><p>浏览器只负责完成认证和操作本机 API。访问令牌保存在 CLI 的私有缓存中,不写入 localStorage,也不返回给页面。</p><div class="actions"><button class="button primary" data-action="login">使用 Keycloak 登录</button></div><div id="notice">'+error+'</div></div></div>'}
34
+ function overview(){const u=state.user||{};const roles=[...(u.realmRoles||[]),...Object.values(u.clientRoles||{}).flat()];return '<div class="content"><section class="hero"><div><div class="eyebrow">Authenticated workstation</div><h2>本机会话已接入研发控制面</h2><p>从同一处检查身份、调整 CLI 配置,并以结构化参数运行受支持命令。所有网络请求继续由 CLI 注入 Bearer Token。</p></div><div class="session-track"><div class="track-node live"><span>身份</span><strong>'+esc(u.username||'已登录')+'</strong></div><div class="track-node"><span>API</span><strong>'+esc(state.config.apiBaseUrl?'已配置':'未配置')+'</strong></div><div class="track-node"><span>角色</span><strong>'+roles.length+'</strong></div></div></section><section class="grid"><article class="card"><div class="eyebrow">Session</div><div class="metric">'+esc(state.expiresAt?new Date(state.expiresAt*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'—')+' <small>访问令牌到期</small></div><p>需要时由 CLI 使用 refresh token 自动续期。</p></article><article class="card"><div class="eyebrow">Commands</div><div class="metric">'+state.commands.length+' <small>条能力契约</small></div><p>网页执行器只接受目录中声明的命令。</p></article><article class="card"><div class="eyebrow">Endpoint</div><div class="metric">'+(state.config.apiBaseUrl?'READY':'SETUP')+'</div><p>'+esc(state.config.apiBaseUrl||'请在基础配置中填写平台 API 地址')+'</p></article><article class="card wide"><h3>当前身份</h3><p>'+esc(u.displayName||u.username||'-')+' · '+esc(u.email||'-')+'</p><div class="actions">'+roles.slice(0,8).map((r)=>'<span class="tag">'+esc(r)+'</span>').join('')+(roles.length>8?'<span class="tag public">+'+(roles.length-8)+'</span>':'')+'</div></article><article class="card"><h3>会话操作</h3><p>强制刷新身份资料,或清除本机 Keycloak 缓存。</p><div class="actions"><button class="button" data-action="reload">刷新状态</button><button class="button danger" data-action="logout">退出登录</button></div></article></section></div>'}
35
+ function instant(value){if(!value)return null;const number=typeof value==='number'?(value<1000000000000?value*1000:value):Date.parse(value);return Number.isFinite(number)?number:null}
36
+ function timeText(value){const at=instant(value);return at?new Date(at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'—'}
37
+ function windowName(window,fallback){const minutes=window?.windowMinutes;if(!minutes)return fallback;if(minutes%1440===0)return (minutes/1440)+'d';if(minutes%60===0)return (minutes/60)+'h';return minutes+'m'}
38
+ function quotaWindowHasData(window){if(!window)return false;const minutes=window.windowMinutes;const reset=window.resetAt;const hasMinutes=typeof minutes==='number'&&Number.isFinite(minutes)&&minutes>0;const hasReset=(typeof reset==='number'&&Number.isFinite(reset)&&reset>0)||(typeof reset==='string'&&Number.isFinite(Date.parse(reset)));const hasUsage=typeof window.usedPercent==='number'&&Number.isFinite(window.usedPercent)&&window.usedPercent>0;return hasMinutes||hasReset||hasUsage}
39
+ function quotaLine(window,fallback){if(!quotaWindowHasData(window)||typeof window.usedPercent!=='number')return '';const remain=Math.max(0,Math.min(100,100-window.usedPercent));return '<div class="quota-line" title="额度重置时间 '+esc(timeText(window.resetAt))+'"><span>'+esc(windowName(window,fallback))+'</span><progress max="100" value="'+remain.toFixed(1)+'"></progress><strong>'+remain.toFixed(0)+'%</strong></div>'}
40
+ function quotaCell(quota){if(!quota)return '<span class="table-meta">暂无额度采样</span>';const lines=quotaLine(quota.primary,'主窗口')+quotaLine(quota.secondary,'次窗口');const detail=[quota.planType,quota.model,quota.activeLimit!=null?'limit '+quota.activeLimit:null].filter(Boolean).join(' · ');return '<div class="quota-stack">'+(lines||'<span class="table-meta">额度窗口暂无数据</span>')+'<div class="table-meta">'+esc(detail||'已采样')+'<br>采样 '+esc(timeText(quota.updatedAt))+'</div></div>'}
41
+ function newerQuota(localQuota,remoteQuota){if(!localQuota)return remoteQuota;if(!remoteQuota)return localQuota;return (instant(remoteQuota.updatedAt)||0)>=(instant(localQuota.updatedAt)||0)?remoteQuota:localQuota}
42
+ function mergedChatgptAccounts(){const local=chatgptLocal?.accounts||[];const remote=chatgptRemote?.accounts||[];const used=new Set();const rows=local.map((item)=>{const index=remote.findIndex((candidate,i)=>!used.has(i)&&item.accountId&&candidate.accountId===item.accountId);const match=index>=0?remote[index]:null;if(index>=0)used.add(index);return {key:item.id,local:item,remote:match,label:item.label,email:item.email,accountId:item.accountId||match?.accountId,enabled:item.enabled,selected:item.selected,quota:newerQuota(item.quota,match?.quota)}});remote.forEach((item,index)=>{if(!used.has(index))rows.push({key:'remote_'+item.id,local:null,remote:item,label:item.label,email:null,accountId:item.accountId,enabled:true,selected:item.isDefault,quota:item.quota})});return rows}
43
+ function syncTag(row){if(chatgptRemote?.status==='LOADING')return '<span class="tag waiting">正在同步</span>';if(chatgptRemote?.status==='FAILED')return '<span class="tag failed">远程同步失败</span>';if(chatgptRemote?.status==='NOT_CONFIGURED')return '<span class="tag waiting">未配置平台</span>';if(row.local&&row.remote)return '<span class="tag success">已同步</span>';if(row.local)return '<span class="tag local">仅本地</span>';return '<span class="tag success">平台可用</span>'}
44
+ function remoteStage(){const remote=chatgptRemote;if(!remote||remote.status==='LOADING')return {tag:'<span class="tag waiting"><span class="spin">↻</span>&nbsp;连接中</span>',title:'正在连接平台',detail:'本地账号已经可见,正在读取远程可用账号。'};if(remote.status==='SUCCESS')return {tag:'<span class="tag success">同步成功</span>',title:remote.accounts.length+' 个平台账号',detail:'完成于 '+timeText(remote.syncedAt)};if(remote.status==='FAILED')return {tag:'<span class="tag failed">远程同步失败</span>',title:'保留本地结果',detail:remote.error?.message||'无法连接远程账号服务'};return {tag:'<span class="tag waiting">未配置</span>',title:'未配置平台地址',detail:'可在基础配置中填写平台 API 地址。'}}
45
+ function accountRows(rows){return rows.map((row)=>'<tr><td><div class="account-name"><strong>'+esc(row.label)+'</strong><small>'+esc(row.email||row.accountId||row.key)+'</small></div></td><td><div class="status-stack">'+(row.enabled?'<span class="tag success">可用</span>':'<span class="tag failed">已停用</span>')+(row.selected?'<span class="tag role">客户端已选</span>':'')+'</div></td><td>'+quotaCell(row.quota)+'</td><td><div class="table-meta">Token '+esc(row.local?'本地可用':'按需从平台获取')+'<br>到期 '+esc(timeText(row.local?.accessExpiresAt||row.remote?.accessExpiresAt))+'</div></td><td><div class="status-stack">'+syncTag(row)+'</div></td></tr>').join('')}
46
+ function chatgptPage(){const rows=mergedChatgptAccounts();const remote=remoteStage();const localReady=chatgptLocal?.status==='READY';const localCount=chatgptLocal?.available??0;const failure=chatgptRemote?.status==='FAILED'?'<div class="notice error"><strong>远程同步失败。</strong> '+esc(chatgptRemote.error?.message||'平台暂时不可用')+';本地账号数据仍可正常查看。</div>':'';return '<div class="content"><section class="hero"><div><div class="eyebrow">Local first / Remote second</div><h2>ChatGPT 账号运行视图</h2><p>先读取 codex-multi-auth 本地账号与额度缓存,再连接平台补充当前用户可用账号。页面永不接收 access token 或 refresh token。</p></div><div class="session-track"><div class="track-node live"><span>本地可用</span><strong>'+localCount+'</strong></div><div class="track-node"><span>汇总账号</span><strong>'+rows.length+'</strong></div><div class="track-node"><span>远程</span><strong>'+esc(chatgptRemote?.status||'WAITING')+'</strong></div></div></section><section class="sync-board"><article class="sync-stage"><div class="eyebrow">01 / Local pool</div><strong>'+(localReady?localCount+' 个本地可用账号':'正在读取本地账号')+'</strong><p>'+(localReady?'读取完成于 '+timeText(chatgptLocal.readAt):'优先读取 codex-multi-auth 安全摘要。')+'</p></article><div class="sync-link" aria-label="然后连接远程"></div><article class="sync-stage"><div class="eyebrow">02 / Platform service</div>'+remote.tag+'<strong>'+esc(remote.title)+'</strong><p>'+esc(remote.detail)+'</p></article></section>'+failure+'<section class="table-card"><header class="table-head"><div><h3>可用账号与剩余额度</h3><p>额度优先展示本地或服务器端更新较新的安全快照。</p></div><button class="button" data-action="chatgpt-refresh" '+(chatgptLoading?'disabled':'')+'>'+(chatgptLoading?'<span class="spin">↻</span> 同步中':'↻ 重新同步')+'</button></header>'+(rows.length?'<div class="account-table-wrap"><table class="account-table"><thead><tr><th>账号</th><th>可用状态</th><th>剩余 limit</th><th>凭据时效</th><th>同步状态</th></tr></thead><tbody>'+accountRows(rows)+'</tbody></table></div>':'<div class="empty-state">'+(chatgptLoading?'正在读取本地账号…':'未发现本地或平台可用账号')+'</div>')+'</section></div>'}
47
+ function configPage(){const c=state.config;return '<div class="content"><section class="grid"><article class="card full"><div class="eyebrow">Configuration file</div><h3>基础配置</h3><p>与 <code>numa config --set</code> 使用同一份配置和校验逻辑。</p><form id="config-form" class="form-grid"><div class="field"><label>界面语言</label><select name="locale"><option '+(c.locale==='zh-CN'?'selected':'')+'>zh-CN</option><option '+(c.locale==='en-US'?'selected':'')+'>en-US</option></select></div><div class="field"><label>默认输出</label><select name="output"><option '+(c.output==='text'?'selected':'')+'>text</option><option '+(c.output==='json'?'selected':'')+'>json</option></select></div><div class="field span-2"><label>平台 API 地址</label><input name="apiBaseUrl" value="'+esc(c.apiBaseUrl)+'" placeholder="https://platform.example/api"></div><div class="field span-2"><label>npm registry</label><input name="registryUrl" value="'+esc(c.registryUrl)+'"></div><label class="field span-2"><span>更新检查</span><select name="checkUpdates"><option value="true" '+(c.checkUpdates?'selected':'')+'>启用</option><option value="false" '+(!c.checkUpdates?'selected':'')+'>关闭</option></select></label><div class="actions span-2"><button class="button primary" type="submit">保存配置</button></div></form><div id="notice"></div></article></section></div>'}
48
+ function commandsPage(){return '<div class="content"><section class="card full"><div class="command-toolbar"><div class="field grow"><label>过滤命令</label><input id="command-search" placeholder="例如 codex、media、auth"></div></div><div id="command-list" class="command-list">'+commandCards('')+'</div></section></div>'}
49
+ function commandCards(query){const q=query.toLowerCase();return state.commands.filter((c)=>(c.command+' '+c.summary).toLowerCase().includes(q)).map((c)=>'<article class="command"><div class="command-head"><code>'+esc(c.command)+'</code><span class="tag '+esc(c.access)+'">'+esc(c.access)+'</span></div><p>'+esc(c.summary)+'</p><small>'+esc(c.examples[0]||'')+'</small><div class="actions mt-10"><button class="button" data-load="'+esc(JSON.stringify(commandPrefix(c.command)))+'">载入执行器</button></div></article>').join('')||'<p>没有匹配命令。</p>'}
50
+ function commandPrefix(command){return command.split(/\s+/).filter((part)=>!part.startsWith('[')&&!part.startsWith('<')&&!part.startsWith('--'))}
51
+ function runnerPage(){return '<div class="content"><section class="grid"><article class="card"><div class="eyebrow">Structured argv</div><h3>安全执行器</h3><p>使用 JSON 数组传入参数,不经过 shell。长驻或交互命令需要在终端运行。</p><form id="runner-form"><div class="field"><label>命令参数</label><textarea id="argv" class="mono">["doctor"]</textarea></div><label class="field mt-12"><span>危险操作确认</span><select id="confirm"><option value="false">未确认</option><option value="true">确认执行删除/重置</option></select></label><div class="actions mt-14"><button class="button primary" type="submit">运行命令</button></div></form></article><article class="card wide"><div class="eyebrow">Result stream</div><h3>结构化输出</h3><pre id="output" class="output">'+esc(output)+'</pre></article></section></div>'}
52
+ async function loadChatgpt(){if(chatgptLoading)return;chatgptLoading=true;chatgptLocal=null;chatgptRemote={status:'LOADING',accounts:[]};render();try{chatgptLocal=await api('/api/chatgpt/local')}catch(error){chatgptLocal={status:'FAILED',accounts:[],available:0,total:0,error:{message:error.message}}}render();try{chatgptRemote=await api('/api/chatgpt/remote')}catch(error){chatgptRemote={status:'FAILED',accounts:[],syncedAt:Date.now(),error:{message:error.message}}}finally{chatgptLoading=false;render()}}
53
+ function render(){if(!state.loggedIn){app.innerHTML=loginGate();bind();return}const page=active==='overview'?overview():active==='chatgpt'?chatgptPage():active==='config'?configPage():active==='commands'?commandsPage():runnerPage();app.innerHTML=shell(page);bind()}
54
+ function notice(message,type='success'){const target=document.getElementById('notice');if(target)target.innerHTML='<div class="notice '+type+'">'+esc(message)+'</div>'}
55
+ function bindCommandLoaders(){document.querySelectorAll('[data-load]').forEach((b)=>b.addEventListener('click',()=>{const argv=JSON.parse(b.dataset.load);active='runner';render();document.getElementById('argv').value=JSON.stringify(argv)}))}
56
+ function bind(){document.querySelectorAll('[data-nav]').forEach((b)=>b.addEventListener('click',()=>{active=b.dataset.nav;render();if(active==='chatgpt'&&!chatgptLocal&&!chatgptLoading)void loadChatgpt()}));document.querySelector('[data-action="chatgpt-refresh"]')?.addEventListener('click',()=>void loadChatgpt());document.querySelector('[data-action="login"]')?.addEventListener('click',async()=>{try{notice('正在启动登录…');const result=await api('/api/auth/login',{method:'POST',body:'{}'});location.href=result.authorizationUrl}catch(e){notice(e.message,'error')}});document.querySelector('[data-action="reload"]')?.addEventListener('click',()=>bootstrap());document.querySelector('[data-action="logout"]')?.addEventListener('click',async()=>{if(!confirm('清除本机 Keycloak 登录缓存?'))return;await api('/api/auth/logout',{method:'POST',body:'{}'});location.reload()});document.getElementById('config-form')?.addEventListener('submit',async(e)=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.currentTarget));data.checkUpdates=data.checkUpdates==='true';try{const result=await api('/api/config',{method:'PUT',body:JSON.stringify(data)});state.config=result.config;notice('配置已保存')}catch(err){notice(err.message,'error')}});document.getElementById('command-search')?.addEventListener('input',(e)=>{document.getElementById('command-list').innerHTML=commandCards(e.target.value);bindCommandLoaders()});bindCommandLoaders();document.getElementById('runner-form')?.addEventListener('submit',async(e)=>{e.preventDefault();try{const argv=JSON.parse(document.getElementById('argv').value);const confirmed=document.getElementById('confirm')?.value==='true';output='运行中…';render();const result=await api('/api/commands/run',{method:'POST',body:JSON.stringify({argv,confirm:confirmed})});output=result.stdout||result.stderr||JSON.stringify(result,null,2);render()}catch(err){output='错误: '+err.message;render()}})}
57
+ bootstrap();
58
+ `;
59
+ //# sourceMappingURL=web-console-page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-console-page.js","sourceRoot":"","sources":["../src/web-console-page.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,YAAY,GAAG;;;;;;;;;;;;QAYpB,CAAC;AAET,MAAM,CAAC,MAAM,WAAW,GAAG;;;;;;;;;;CAU1B,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCzB,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { type LocalChatgptState, type RemoteChatgptState } from "./chatgpt-console.js";
2
+ export interface ConsoleCommandResult {
3
+ exitCode: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ export interface WebConsoleOptions {
8
+ host?: string;
9
+ port?: number;
10
+ openBrowser?: boolean;
11
+ commandRunner?: (argv: string[]) => Promise<ConsoleCommandResult>;
12
+ localChatgptLoader?: () => Promise<LocalChatgptState>;
13
+ remoteChatgptLoader?: () => Promise<RemoteChatgptState>;
14
+ }
15
+ export interface WebConsoleHandle {
16
+ url: string;
17
+ close: () => Promise<void>;
18
+ waitUntilClosed: () => Promise<void>;
19
+ }
20
+ export declare function validateConsoleCommand(input: unknown, confirmed?: boolean): string[];
21
+ export declare function runConsoleCommand(argv: string[]): Promise<ConsoleCommandResult>;
22
+ export declare function startWebConsole(options?: WebConsoleOptions): Promise<WebConsoleHandle>;
@@ -0,0 +1,335 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import process from "node:process";
5
+ import open from "open";
6
+ import { CONFIG_KEYS, loadAppConfig, saveAppConfig, updateConfigValue } from "./app-config.js";
7
+ import { COMMAND_CATALOG } from "./command-catalog.js";
8
+ import { loadLocalChatgptState, loadRemoteChatgptState } from "./chatgpt-console.js";
9
+ import { loadConfig } from "./config.js";
10
+ import { currentIdentity } from "./identity.js";
11
+ import { AuthError, completeBrowserLogin, deleteTokenCache, nowSeconds, readTokenCache, startBrowserLogin } from "./oauth.js";
12
+ import { CONSOLE_CSS, CONSOLE_HTML, CONSOLE_JS } from "./web-console-page.js";
13
+ const DEFAULT_CONSOLE_PORT = 17654;
14
+ const MAX_BODY_BYTES = 1024 * 1024;
15
+ const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
16
+ const COMMAND_TIMEOUT_MS = 120_000;
17
+ const COOKIE_NAME = "numa_console_session";
18
+ function commandPrefix(descriptor) {
19
+ const prefix = [];
20
+ for (const part of descriptor.command.split(/\s+/u)) {
21
+ if (part.startsWith("[") || part.startsWith("<") || part.startsWith("--"))
22
+ break;
23
+ prefix.push(part);
24
+ }
25
+ return prefix;
26
+ }
27
+ function startsWith(argv, prefix) {
28
+ return prefix.every((value, index) => argv[index] === value);
29
+ }
30
+ export function validateConsoleCommand(input, confirmed = false) {
31
+ if (!Array.isArray(input) || input.length === 0 || input.length > 32) {
32
+ throw new Error("命令参数必须是包含 1-32 个字符串的 JSON 数组。");
33
+ }
34
+ const argv = input.map((value) => {
35
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096 || value.includes("\0")) {
36
+ throw new Error("每个命令参数必须是 1-4096 字符的非空字符串。");
37
+ }
38
+ return value;
39
+ });
40
+ const descriptor = COMMAND_CATALOG
41
+ .map((item) => ({ item, prefix: commandPrefix(item) }))
42
+ .filter(({ prefix }) => prefix.length > 0 && startsWith(argv, prefix))
43
+ .sort((left, right) => right.prefix.length - left.prefix.length)[0];
44
+ if (!descriptor)
45
+ throw new Error("该命令不在 numa 命令目录中。");
46
+ const commandKey = descriptor.prefix.join(" ");
47
+ if (["login", "console", "serve", "codex run", "codex app", "codex auth-agent"].includes(commandKey)) {
48
+ throw new Error(`${commandKey} 是浏览器、交互或长驻命令,请在终端直接运行。`);
49
+ }
50
+ if (commandKey === "config" && argv.length === 1) {
51
+ throw new Error("基础配置请使用控制台配置页,或为 config 提供 --show/--set 参数。");
52
+ }
53
+ const destructive = commandKey === "auth logout" ||
54
+ commandKey === "codex reset" ||
55
+ (commandKey === "config" && argv.includes("--reset"));
56
+ if (destructive && !confirmed) {
57
+ throw new Error("该命令会删除或重置本地状态,请先启用危险操作确认。");
58
+ }
59
+ return argv.includes("--json") ? argv : [...argv, "--json"];
60
+ }
61
+ export async function runConsoleCommand(argv) {
62
+ const cliEntry = process.argv[1];
63
+ if (!cliEntry)
64
+ throw new Error("无法定位 numa CLI 入口。");
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn(process.execPath, [cliEntry, ...argv], {
67
+ cwd: process.cwd(),
68
+ env: process.env,
69
+ shell: false,
70
+ stdio: ["ignore", "pipe", "pipe"]
71
+ });
72
+ let stdout = "";
73
+ let stderr = "";
74
+ let settled = false;
75
+ let timeout;
76
+ const finish = (error, exitCode = 1) => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ if (timeout)
81
+ clearTimeout(timeout);
82
+ if (error)
83
+ reject(error);
84
+ else
85
+ resolve({ exitCode, stdout, stderr });
86
+ };
87
+ const append = (current, chunk) => {
88
+ const next = current + chunk.toString("utf8");
89
+ if (Buffer.byteLength(next, "utf8") > MAX_COMMAND_OUTPUT_BYTES) {
90
+ child.kill();
91
+ finish(new Error("命令输出超过 1 MiB,已停止执行。"));
92
+ }
93
+ return next;
94
+ };
95
+ child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
96
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
97
+ child.once("error", (error) => finish(error));
98
+ child.once("close", (code) => finish(undefined, code ?? 1));
99
+ timeout = setTimeout(() => {
100
+ child.kill();
101
+ finish(new Error("命令执行超过 120 秒,已停止。"));
102
+ }, COMMAND_TIMEOUT_MS);
103
+ timeout.unref();
104
+ });
105
+ }
106
+ function consoleHost() {
107
+ const host = process.env.NUMA_CONSOLE_HOST?.trim() || "127.0.0.1";
108
+ if (!["127.0.0.1", "::1", "localhost"].includes(host)) {
109
+ throw new Error("NUMA_CONSOLE_HOST 只能绑定回环地址。不能把携带本机权限的控制台暴露到局域网。");
110
+ }
111
+ return host;
112
+ }
113
+ function consolePort() {
114
+ const raw = process.env.NUMA_CONSOLE_PORT;
115
+ if (!raw)
116
+ return DEFAULT_CONSOLE_PORT;
117
+ const value = Number.parseInt(raw, 10);
118
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
119
+ throw new Error("NUMA_CONSOLE_PORT 必须是 1-65535 之间的端口。");
120
+ }
121
+ return value;
122
+ }
123
+ function securityHeaders(response, contentType) {
124
+ response.setHeader("Content-Type", contentType);
125
+ response.setHeader("Cache-Control", "no-store");
126
+ response.setHeader("X-Content-Type-Options", "nosniff");
127
+ response.setHeader("X-Frame-Options", "DENY");
128
+ response.setHeader("Referrer-Policy", "no-referrer");
129
+ response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
130
+ }
131
+ function sendJson(response, status, body) {
132
+ securityHeaders(response, "application/json; charset=utf-8");
133
+ response.writeHead(status);
134
+ response.end(`${JSON.stringify(body)}\n`);
135
+ }
136
+ function sendError(response, error, status = 400) {
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ const code = error instanceof AuthError ? error.code : "console_error";
139
+ sendJson(response, status, { ok: false, error: { code, message } });
140
+ }
141
+ function cookieValue(request, name) {
142
+ for (const part of (request.headers.cookie ?? "").split(";")) {
143
+ const separator = part.indexOf("=");
144
+ if (separator > 0 && part.slice(0, separator).trim() === name) {
145
+ return part.slice(separator + 1).trim();
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ async function jsonBody(request) {
151
+ const chunks = [];
152
+ let size = 0;
153
+ for await (const chunk of request) {
154
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
155
+ size += buffer.length;
156
+ if (size > MAX_BODY_BYTES)
157
+ throw new Error("请求体不能超过 1 MiB。");
158
+ chunks.push(buffer);
159
+ }
160
+ if (chunks.length === 0)
161
+ return {};
162
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
163
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
164
+ throw new Error("请求体必须是 JSON 对象。");
165
+ }
166
+ return parsed;
167
+ }
168
+ function safeConfig(config) {
169
+ return { ...config };
170
+ }
171
+ async function waitForLogin(session) {
172
+ try {
173
+ while (!session.code && !session.error && session.expiresAt > nowSeconds()) {
174
+ await new Promise((resolve) => setTimeout(resolve, 250));
175
+ }
176
+ await completeBrowserLogin(loadConfig(), session);
177
+ }
178
+ finally {
179
+ await session.close().catch(() => undefined);
180
+ }
181
+ }
182
+ export async function startWebConsole(options = {}) {
183
+ const host = options.host ?? consoleHost();
184
+ if (!["127.0.0.1", "::1", "localhost"].includes(host)) {
185
+ throw new Error("Web 控制台只能绑定回环地址。");
186
+ }
187
+ const requestedPort = options.port ?? consolePort();
188
+ const sessionSecret = randomBytes(32).toString("base64url");
189
+ const csrfToken = randomBytes(32).toString("base64url");
190
+ const commandRunner = options.commandRunner ?? runConsoleCommand;
191
+ const localChatgptLoader = options.localChatgptLoader ?? loadLocalChatgptState;
192
+ const remoteChatgptLoader = options.remoteChatgptLoader ?? loadRemoteChatgptState;
193
+ let baseUrl = "";
194
+ let loginError;
195
+ let closedResolve;
196
+ const closed = new Promise((resolve) => { closedResolve = resolve; });
197
+ const server = createServer((request, response) => {
198
+ void (async () => {
199
+ const expectedHost = new URL(baseUrl).host;
200
+ if (request.headers.host !== expectedHost) {
201
+ sendError(response, new Error("Host header is not allowed."), 421);
202
+ return;
203
+ }
204
+ const url = new URL(request.url ?? "/", baseUrl);
205
+ if (request.method === "GET" && url.pathname === "/") {
206
+ securityHeaders(response, "text/html; charset=utf-8");
207
+ response.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionSecret}; HttpOnly; SameSite=Strict; Path=/`);
208
+ response.end(CONSOLE_HTML);
209
+ return;
210
+ }
211
+ if (request.method === "GET" && url.pathname === "/assets/console.css") {
212
+ securityHeaders(response, "text/css; charset=utf-8");
213
+ response.end(CONSOLE_CSS);
214
+ return;
215
+ }
216
+ if (request.method === "GET" && url.pathname === "/assets/console.js") {
217
+ securityHeaders(response, "text/javascript; charset=utf-8");
218
+ response.end(CONSOLE_JS);
219
+ return;
220
+ }
221
+ if (cookieValue(request, COOKIE_NAME) !== sessionSecret) {
222
+ sendError(response, new Error("控制台会话无效,请重新打开页面。"), 401);
223
+ return;
224
+ }
225
+ if (request.method === "GET" && url.pathname === "/api/bootstrap") {
226
+ const config = loadConfig();
227
+ const cache = await readTokenCache(config);
228
+ let user;
229
+ if (cache) {
230
+ try {
231
+ const { raw: _raw, ...safeIdentity } = await currentIdentity(config);
232
+ user = safeIdentity;
233
+ loginError = undefined;
234
+ }
235
+ catch (error) {
236
+ loginError = error instanceof Error ? error.message : String(error);
237
+ }
238
+ }
239
+ sendJson(response, 200, {
240
+ ok: true,
241
+ csrfToken,
242
+ loggedIn: Boolean(user),
243
+ user,
244
+ expiresAt: cache?.expiresAt ?? null,
245
+ refreshExpiresAt: cache?.refreshExpiresAt ?? null,
246
+ config: safeConfig(await loadAppConfig()),
247
+ commands: COMMAND_CATALOG,
248
+ loginError: loginError ?? null
249
+ });
250
+ return;
251
+ }
252
+ if (request.method === "GET" && url.pathname === "/api/chatgpt/local") {
253
+ sendJson(response, 200, await localChatgptLoader());
254
+ return;
255
+ }
256
+ if (request.method === "GET" && url.pathname === "/api/chatgpt/remote") {
257
+ sendJson(response, 200, await remoteChatgptLoader());
258
+ return;
259
+ }
260
+ if (request.method !== "GET" && request.headers["x-numa-console-csrf"] !== csrfToken) {
261
+ sendError(response, new Error("CSRF validation failed."), 403);
262
+ return;
263
+ }
264
+ if (request.method === "POST" && url.pathname === "/api/auth/login") {
265
+ const session = await startBrowserLogin({ ...loadConfig(), openBrowser: false }, {
266
+ successRedirectUrl: `${baseUrl}/?login=complete`,
267
+ failureRedirectUrl: `${baseUrl}/?login=failed`
268
+ });
269
+ loginError = undefined;
270
+ void waitForLogin(session).catch((error) => {
271
+ loginError = error instanceof Error ? error.message : String(error);
272
+ });
273
+ sendJson(response, 200, { ok: true, authorizationUrl: session.authorizationUrl });
274
+ return;
275
+ }
276
+ if (request.method === "POST" && url.pathname === "/api/auth/logout") {
277
+ await deleteTokenCache(loadConfig());
278
+ sendJson(response, 200, { ok: true, loggedOut: true });
279
+ return;
280
+ }
281
+ if (request.method === "PUT" && url.pathname === "/api/config") {
282
+ const body = await jsonBody(request);
283
+ let config = await loadAppConfig();
284
+ for (const key of CONFIG_KEYS) {
285
+ if (!(key in body))
286
+ continue;
287
+ const raw = body[key];
288
+ if (typeof raw !== "string" && typeof raw !== "boolean") {
289
+ throw new Error(`配置项 ${key} 类型无效。`);
290
+ }
291
+ config = updateConfigValue(config, `${key}=${String(raw)}`);
292
+ }
293
+ config = await saveAppConfig(config);
294
+ sendJson(response, 200, { ok: true, config: safeConfig(config) });
295
+ return;
296
+ }
297
+ if (request.method === "POST" && url.pathname === "/api/commands/run") {
298
+ const body = await jsonBody(request);
299
+ const argv = validateConsoleCommand(body.argv, body.confirm === true);
300
+ const result = await commandRunner(argv);
301
+ sendJson(response, 200, {
302
+ ok: result.exitCode === 0,
303
+ ...result
304
+ });
305
+ return;
306
+ }
307
+ sendError(response, new Error("Not found."), 404);
308
+ })().catch((error) => sendError(response, error, 400));
309
+ });
310
+ await new Promise((resolve, reject) => {
311
+ server.once("error", reject);
312
+ server.listen(requestedPort, host, () => resolve());
313
+ });
314
+ const address = server.address();
315
+ if (!address || typeof address === "string")
316
+ throw new Error("无法读取控制台监听地址。");
317
+ const displayHost = host === "::1" ? "[::1]" : host;
318
+ baseUrl = `http://${displayHost}:${address.port}`;
319
+ server.once("close", () => closedResolve?.());
320
+ if (options.openBrowser !== false) {
321
+ await open(baseUrl).catch(() => undefined);
322
+ }
323
+ return {
324
+ url: baseUrl,
325
+ close: () => new Promise((resolve, reject) => {
326
+ if (!server.listening) {
327
+ resolve();
328
+ return;
329
+ }
330
+ server.close((error) => error ? reject(error) : resolve());
331
+ }),
332
+ waitUntilClosed: () => closed
333
+ };
334
+ }
335
+ //# sourceMappingURL=web-console.js.map