Deploy grok2api (jiujiu532) to HF Space: port 7860, bucket persistence at /app/data
48d895c verified | <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title data-i18n="config.pageTitle">Grok2API - ้ ็ฝฎ็ฎก็</title> | |
| <link rel="icon" href="/favicon.ico?v={{APP_VERSION}}"> | |
| <link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet"> | |
| <link href="/static/css/app.css?v={{APP_VERSION}}" rel="stylesheet"> | |
| <style> | |
| body { background: #FAF9F5 } | |
| /* Page header */ | |
| .page-hd { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px } | |
| .page-title{ font-size:22px; font-weight:700; line-height:1.2 } | |
| .page-sub { font-size:13px; color:var(--fg-muted); margin-top:5px } | |
| .page-actions { display:flex; gap:8px; align-items:center; flex-shrink:0 } | |
| .page-action-btn { | |
| height:32px; padding:0 14px; border-radius:999px; | |
| display:inline-flex; align-items:center; justify-content:center; gap:6px; | |
| font-size:13px; font-weight:600; | |
| border:1px solid #e6e6e6; background:#fafafa; color:#444; | |
| } | |
| .page-action-btn:hover { background:#f3f3f3; border-color:#dcdcdc } | |
| .page-action-btn-primary { background:#111; border-color:#111; color:#fff } | |
| .page-action-btn-primary:hover { background:#222; border-color:#222 } | |
| .page-action-btn:disabled { opacity:.4; pointer-events:none } | |
| /* Section nav tabs */ | |
| .cfg-nav { | |
| display:flex; | |
| align-items:center; | |
| gap:6px; | |
| flex-wrap:wrap; | |
| margin-bottom:16px; | |
| padding:0; | |
| } | |
| .cfg-tab { | |
| height:30px; | |
| padding:0 12px; | |
| border-radius:999px; | |
| font-size:12px; | |
| font-weight:500; | |
| color:#8f8f8f; | |
| background:#f5f5f5; | |
| transition:color .15s, background .15s; | |
| cursor:pointer; | |
| } | |
| .cfg-tab:hover { color:#555; background:#f1f1f1 } | |
| .cfg-tab.active { | |
| background:#111; | |
| color:#fff; | |
| font-weight:600; | |
| } | |
| .cfg-tab .dot { | |
| display:none; | |
| width:6px; | |
| height:6px; | |
| border-radius:50%; | |
| background:currentColor; | |
| margin-left:5px; | |
| vertical-align:middle; | |
| opacity:.45; | |
| } | |
| .cfg-tab.dirty .dot { display:inline-block } | |
| /* Section panels */ | |
| .cfg-panel { display:none } | |
| .cfg-panel.active { display:block } | |
| /* Field rows */ | |
| .cfg-group { | |
| background:#fff; | |
| border:none; | |
| border-radius:12px; | |
| overflow:hidden; | |
| margin-bottom:12px; | |
| } | |
| .cfg-group-title { | |
| font-size:11px; | |
| font-weight:700; | |
| color:#8d8d8d; | |
| text-transform:uppercase; | |
| letter-spacing:.08em; | |
| padding:12px 16px 6px; | |
| background:#fff; | |
| border-bottom:none; | |
| } | |
| .cfg-row { | |
| display:grid; | |
| grid-template-columns:minmax(0, 2fr) minmax(300px, 1fr); | |
| gap:18px; | |
| align-items:center; | |
| padding:11px 16px; | |
| border-bottom:1px solid #f5f5f5; | |
| transition:background .15s; | |
| } | |
| .cfg-row:last-child { border-bottom:none } | |
| .cfg-row:hover { background:#fcfcfc } | |
| .cfg-row.modified { background:#fffaf0 } | |
| .cfg-row.modified:hover { background:#fff6e7 } | |
| .cfg-label-col { min-width:0; display:grid; gap:3px; align-content:start } | |
| .cfg-label { | |
| display:inline-flex; | |
| align-items:center; | |
| gap:6px; | |
| font-size:12px; | |
| font-weight:600; | |
| color:#222; | |
| line-height:1.35; | |
| } | |
| .cfg-desc { max-width:520px; font-size:11px; color:#9a9a9a; line-height:1.45 } | |
| .cfg-help { | |
| position:relative; | |
| width:16px; | |
| height:16px; | |
| display:inline-flex; | |
| align-items:center; | |
| justify-content:center; | |
| padding:0; | |
| border-radius:50%; | |
| border:1px solid #e6e6e6; | |
| background:#fafafa; | |
| color:#8d8d8d; | |
| font-size:11px; | |
| line-height:1; | |
| font-weight:700; | |
| cursor:help; | |
| flex-shrink:0; | |
| } | |
| .cfg-help:hover, | |
| .cfg-help:focus-visible { | |
| color:#222; | |
| border-color:#d6d6d6; | |
| background:#fff; | |
| outline:none; | |
| } | |
| .cfg-help-tip { | |
| position:fixed; | |
| box-sizing:border-box; | |
| max-width:min(320px, calc(100vw - 24px)); | |
| padding:9px 10px; | |
| border-radius:8px; | |
| background:#171717; | |
| color:#fff; | |
| font-size:12px; | |
| font-weight:500; | |
| line-height:1.5; | |
| text-align:left; | |
| white-space:pre-line; | |
| overflow-wrap:anywhere; | |
| box-shadow:0 8px 24px rgba(0,0,0,.16); | |
| opacity:0; | |
| pointer-events:none; | |
| transform:translateY(3px); | |
| transition:opacity .15s, transform .15s; | |
| z-index:1000; | |
| } | |
| .cfg-help-tip.visible { | |
| opacity:1; | |
| transform:translateY(0); | |
| } | |
| .cfg-help-tip::before { | |
| content:''; | |
| position:absolute; | |
| left:var(--arrow-left, 50%); | |
| width:8px; | |
| height:8px; | |
| background:#171717; | |
| transform:translateX(-50%) rotate(45deg); | |
| } | |
| .cfg-help-tip[data-placement="top"]::before { bottom:-4px } | |
| .cfg-help-tip[data-placement="bottom"]::before { top:-4px } | |
| .cfg-input-col { min-width:0; display:flex; align-items:center; justify-content:flex-start; gap:8px } | |
| .cfg-input-col.is-bool { justify-content:flex-end } | |
| .cfg-inline-action { | |
| width:16px; | |
| height:16px; | |
| display:inline-flex; | |
| align-items:center; | |
| justify-content:center; | |
| padding:0; | |
| color:#9a9a9a; | |
| border:0; | |
| background:transparent; | |
| cursor:pointer; | |
| flex-shrink:0; | |
| transition:color .15s, opacity .15s, transform .15s; | |
| } | |
| .cfg-inline-action:hover { color:#222; transform:translateY(-.5px) } | |
| .cfg-inline-action:disabled { | |
| opacity:.32; | |
| cursor:not-allowed; | |
| pointer-events:none; | |
| } | |
| .cfg-inline-action svg { width:14px; height:14px; display:block } | |
| /* Inputs */ | |
| .cfg-text { | |
| width:100%; | |
| max-width:none; | |
| height:32px; | |
| padding:0 10px; | |
| font-size:13px; | |
| border-radius:8px; | |
| border:1px solid #ececec; | |
| background:#fbfbfb; | |
| transition:border-color .15s, background .15s; | |
| font-family: inherit; text-align:right; | |
| } | |
| .cfg-text:focus { border-color:#d7d7d7; background:#fff; outline:none; box-shadow:none } | |
| .cfg-text.wide { max-width:none } | |
| .cfg-textarea { | |
| width:100%; | |
| max-width:none; | |
| min-height:72px; | |
| padding:8px 10px; | |
| font-size:12px; | |
| border-radius:10px; | |
| border:1px solid #ececec; | |
| background:#fbfbfb; | |
| resize:vertical; | |
| transition:border-color .15s, background .15s; | |
| font-family: 'Geist Mono', 'Courier New', monospace; line-height:1.5; text-align:right; | |
| } | |
| .cfg-textarea:focus { border-color:#d7d7d7; background:#fff; outline:none } | |
| .cfg-number { | |
| width:100%; | |
| max-width:none; | |
| height:32px; | |
| padding:0 10px; | |
| font-size:13px; | |
| border-radius:8px; | |
| border:1px solid #ececec; | |
| background:#fbfbfb; | |
| text-align:right; | |
| } | |
| .cfg-number:focus { border-color:#d7d7d7; background:#fff; outline:none } | |
| .cfg-select { | |
| width:100%; | |
| height:32px; | |
| padding:0 28px 0 10px; | |
| font-size:13px; | |
| border-radius:8px; | |
| border:1px solid #ececec; | |
| background:#fbfbfb; | |
| appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%23666' stroke-width='2' fill='none'/%3E%3C/svg%3E"); | |
| background-repeat:no-repeat; background-position:right 8px center; | |
| cursor:pointer; text-align:right; | |
| } | |
| .cfg-select:focus { border-color:#d7d7d7; background:#fff; outline:none } | |
| /* Toggle switch */ | |
| .toggle { | |
| position:relative; display:inline-block; width:36px; height:20px; flex-shrink:0; | |
| } | |
| .toggle input { opacity:0; width:0; height:0 } | |
| .toggle-track { | |
| position:absolute; cursor:pointer; inset:0; | |
| background:#ddd; border-radius:20px; transition:background .2s; | |
| } | |
| .toggle-track::before { | |
| content:''; position:absolute; width:14px; height:14px; | |
| left:3px; bottom:3px; background:#fff; border-radius:50%; | |
| transition:transform .2s; box-shadow:0 1px 2px rgba(0,0,0,.15); | |
| } | |
| .toggle input:checked + .toggle-track { background:#111 } | |
| .toggle input:checked + .toggle-track::before { transform:translateX(16px) } | |
| /* Modified badge */ | |
| .cfg-modified-dot { | |
| display:inline-block; width:6px; height:6px; border-radius:50%; | |
| background:#f59e0b; margin-left:6px; vertical-align:middle; | |
| visibility:hidden; | |
| } | |
| .cfg-row.modified .cfg-modified-dot { visibility:visible } | |
| /* Random button */ | |
| .cfg-rand-btn { | |
| width:30px; height:30px; border-radius:999px; | |
| display:inline-flex; align-items:center; justify-content:center; | |
| color:#6a6a6a; border:1px solid #ececec; background:#fafafa; | |
| cursor:pointer; flex-shrink:0; transition:background .15s, border-color .15s; | |
| } | |
| .cfg-rand-btn:hover { background:#f3f3f3; border-color:#dddddd; color:#222 } | |
| /* Save bar */ | |
| .save-bar { | |
| position:fixed; | |
| left:50%; | |
| bottom:18px; | |
| z-index:50; | |
| width:min(calc(100% - 56px), 1280px); | |
| transform:translate(-50%, calc(100% + 20px)); | |
| background:rgba(255,255,255,.96); | |
| border:1px solid #efefef; | |
| border-radius:16px; | |
| padding:12px 16px; | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| gap:12px; | |
| transition:transform .25s ease; | |
| } | |
| .save-bar.visible { transform:translate(-50%, 0) } | |
| .save-bar-msg { font-size:13px; color:var(--fg-muted) } | |
| .save-bar-actions { display:flex; gap:8px } | |
| .save-bar-btn { | |
| height:32px; | |
| padding:0 14px; | |
| border-radius:999px; | |
| font-size:13px; | |
| font-weight:600; | |
| border:1px solid transparent; | |
| cursor:pointer; | |
| } | |
| .save-bar-btn-cancel { border-color:#e6e6e6; background:#fafafa; color:#444 } | |
| .save-bar-btn-cancel:hover { background:#f3f3f3; border-color:#dcdcdc } | |
| .save-bar-btn-save { background:#111; color:#fff } | |
| .save-bar-btn-save:hover { background:#222 } | |
| @media (max-width:640px) { | |
| .cfg-row { grid-template-columns:1fr; gap:8px; align-items:flex-start } | |
| .cfg-input-col { justify-content:flex-start } | |
| .cfg-nav { gap:8px } | |
| .save-bar { | |
| width:calc(100% - 24px); | |
| bottom:12px; | |
| padding:12px; | |
| flex-direction:column; | |
| align-items:stretch; | |
| } | |
| .save-bar-actions { justify-content:flex-end } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- โโ Header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ --> | |
| <div id="admin-header" data-active="/admin/config"></div> | |
| <!-- โโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ --> | |
| <main class="admin-main" style="padding-bottom:80px"> | |
| <div class="page-hd"> | |
| <div> | |
| <div class="page-title" data-i18n="config.pageHeading">้ ็ฝฎ็ฎก็</div> | |
| <div class="page-sub" data-i18n="config.pageSubtitle">็ปไธ็ฎก็่ฟ่กๆถๅๆฐไธๆๅก่กไธบใไฟๅญๅๅณๆถ็ๆ๏ผ้จๅ้ ็ฝฎ้้ๅฏๆๅกใ</div> | |
| </div> | |
| <div class="page-actions"> | |
| <button id="btn-reset" onclick="doReset()" class="page-action-btn"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.708"/><path d="M3 4v5h5"/></svg> | |
| <span data-i18n="config.resetShort">้็ฝฎ</span> | |
| </button> | |
| <button id="btn-save" onclick="doSave()" class="page-action-btn page-action-btn-primary" disabled> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m5 12 4.2 4.2L19 6.5"/></svg> | |
| <span data-i18n="config.saveShort">ไฟๅญ</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Section tabs --> | |
| <div class="cfg-nav" id="cfg-nav"></div> | |
| <!-- Section panels, rendered by JS --> | |
| <div id="cfg-panels"></div> | |
| </main> | |
| <!-- โโ Save bar (bottom) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ --> | |
| <div class="save-bar" id="save-bar"> | |
| <div class="save-bar-msg" id="save-bar-msg" data-i18n="config.unsavedDefault">ๅญๅจๆชไฟๅญ็ๆดๆน</div> | |
| <div class="save-bar-actions"> | |
| <button class="save-bar-btn save-bar-btn-cancel" onclick="doReset()" data-i18n="config.discardShort">ๆค้</button> | |
| <button class="save-bar-btn save-bar-btn-save" onclick="doSave()" data-i18n="config.saveBarShort">ไฟๅญ</button> | |
| </div> | |
| </div> | |
| <script src="/static/js/i18n.js?v={{APP_VERSION}}"></script> | |
| <script src="/static/js/auth.js?v={{APP_VERSION}}"></script> | |
| <script src="/static/js/admin-header.js?v={{APP_VERSION}}"></script> | |
| <script src="/static/js/toast.js?v={{APP_VERSION}}"></script> | |
| <script src="/static/js/footer.js?v={{APP_VERSION}}"></script> | |
| <script> | |
| // โโ Config schema โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // Each group has a `section` (full dotted TOML path, e.g. "proxy.egress"). | |
| // Each field has a `key` (leaf key within that section). | |
| // Full config path = section + '.' + key. | |
| const SCHEMA_DEF = [ | |
| { | |
| id: 'system', label: '็ณป็ป้ ็ฝฎ', labelKey: 'config.schema.tabs.system', | |
| groups: [ | |
| { | |
| title: 'ๅบ็ก่ฎฟ้ฎๆงๅถ', titleKey: 'config.schema.groups.baseAccess', | |
| section: 'app', | |
| fields: [ | |
| { key: 'app_url', label: 'APP ่ฎฟ้ฎๅฐๅ', labelKey: 'config.schema.fields.appUrl.label', type: 'text', desc: 'ๅบ็จๅฏนๅคๆไพๆๅก็ๆ น URL๏ผๆ ผๅผๅฆ https://your-domain.comใๆฌๅฐไปฃ็ๆจกๅผๅฟ ้กป้ ็ฝฎใ', descKey: 'config.schema.fields.appUrl.desc', wide: true }, | |
| { key: 'app_key', label: 'APP ่ฎฟ้ฎๅฏ็ ', labelKey: 'config.schema.fields.appKey.label', type: 'password', desc: '็ฎก็ๅๅฐ้ดๆๅฏ็ ใไฟฎๆนๅๅทฒๆ Session ๅฐ็ซๅณๅคฑๆ๏ผ้้ๆฐ็ปๅฝใ', descKey: 'config.schema.fields.appKey.desc', rand: true }, | |
| { key: 'api_key', label: 'API ่ฐ็จๅฏ้ฅ', labelKey: 'config.schema.fields.apiKey.label', type: 'text', desc: 'OpenAI ๅ ผๅฎน API ็้ดๆๅฏ้ฅใๅคไธชๅผ่ฏทไฝฟ็จ่ฑๆ้ๅทๅ้๏ผ็็ฉบๅ็ฆ็จ้ดๆใ', descKey: 'config.schema.fields.apiKey.desc', rand: true }, | |
| { key: 'webui_enabled', label: 'ๅฏ็จ WebUI', labelKey: 'config.schema.fields.webuiEnabled.label', type: 'bool', desc: 'ๅผๆพ WebUI ๅ่ฝๅ ฅๅฃใๅ ณ้ญๅ็ธๅ ณ่ทฏ็ฑๅฐ่ฟๅ 404ใ', descKey: 'config.schema.fields.webuiEnabled.desc' }, | |
| { key: 'webui_key', label: 'WebUI ่ฎฟ้ฎๅฏ็ ', labelKey: 'config.schema.fields.webuiKey.label', type: 'password', desc: 'WebUI ็่ฎฟ้ฎ้ดๆๅฏ็ ใ้ ็ฝฎๅ่ฎฟ้ฎๆถๅฟ ้กปๆไพใ', descKey: 'config.schema.fields.webuiKey.desc', rand: true, showIf: 'app.webui_enabled' }, | |
| ] | |
| }, | |
| { | |
| title: 'ๆฅๅฟ้ ็ฝฎ', titleKey: 'config.schema.groups.logging', | |
| section: 'logging', | |
| fields: [ | |
| { | |
| key: 'file_level', | |
| label: 'ๆไปถๆฅๅฟ็ญ็บง', | |
| labelKey: 'config.schema.fields.fileLogLevel.label', | |
| type: 'select', | |
| options: [ | |
| { value: 'DEBUG', label: 'DEBUG' }, | |
| { value: 'INFO', label: 'INFO' }, | |
| { value: 'WARNING', label: 'WARNING' }, | |
| { value: 'ERROR', label: 'ERROR' }, | |
| ], | |
| desc: 'ๆฅๅฟๆไปถๅๅ ฅ็ๆไฝ็ญ็บง๏ผๅฏ็ฌ็ซไบๆงๅถๅฐ้ ็ฝฎใ้ป่ฎค INFOใ', | |
| descKey: 'config.schema.fields.fileLogLevel.desc', | |
| }, | |
| { | |
| key: 'max_files', | |
| label: 'ๆฅๅฟไฟ็ๅคฉๆฐ', | |
| labelKey: 'config.schema.fields.logMaxFiles.label', | |
| type: 'number', | |
| desc: 'ๆๅคฉ่ฝฎ่ฝฌๆถๆๅคไฟ็็ๆฅๅฟๆไปถๆฐ้ใ้ป่ฎค 7ใ', | |
| descKey: 'config.schema.fields.logMaxFiles.desc', | |
| }, | |
| ] | |
| }, | |
| ] | |
| }, | |
| { | |
| id: 'features', label: 'ๅบ็จๅ่ฝ', labelKey: 'config.schema.tabs.features', | |
| groups: [ | |
| { | |
| title: 'ๅบๆฌๅ่ฝ่ฎพ็ฝฎ', titleKey: 'config.schema.groups.features', | |
| section: 'features', | |
| fields: [ | |
| { key: 'temporary', label: 'ไธดๆถไผ่ฏ', labelKey: 'config.schema.fields.temporary.label', type: 'bool', desc: 'ๅฏ็จๅ่ฏทๆฑไธไผๅๅ ฅ Grok ไผ่ฏๅๅฒ๏ผ็ญๅไบๆ ็ๅฏน่ฏใ', descKey: 'config.schema.fields.temporary.desc' }, | |
| { key: 'memory', label: 'ๅผๅฏไผ่ฏ่ฎฐๅฟ', labelKey: 'config.schema.fields.memory.label', type: 'bool', desc: 'ๅฏ็จ Grok Memory๏ผๅ ่ฎธๆจกๅ่ทจไผ่ฏ่ฎฐๅฟ็จๆทๅๅฅฝไธไธไธๆใ', descKey: 'config.schema.fields.memory.desc' }, | |
| { key: 'stream', label: '้ป่ฎคๆตๅผ่พๅบ', labelKey: 'config.schema.fields.stream.label', type: 'bool', desc: '่ฏทๆฑๆชๆพๅผๆๅฎ stream ๅๆฐๆถๆ้็จ็้ป่ฎคๅผใ', descKey: 'config.schema.fields.stream.desc' }, | |
| { key: 'thinking', label: '้ป่ฎคๆ่่พๅบ', labelKey: 'config.schema.fields.thinking.label', type: 'bool', desc: '่ฏทๆฑๆชๆพๅผๆๅฎ thinking ๅๆฐๆถๆ้็จ็้ป่ฎคๅผใๅฏ็จๅๅฐๅจ reasoning_content ๅญๆฎต่ฟๅๆ่่ฟ็จใ', descKey: 'config.schema.fields.thinking.desc' }, | |
| { key: 'auto_chat_mode_fallback', label: 'AUTO ่ๅคฉๆจกๅผๅ้', labelKey: 'config.schema.fields.autoChatModeFallback.label', type: 'bool', desc: 'AUTO ่ๅคฉๆจกๅๅจ auto ้ขๅบฆไธๅฏ็จๆถ๏ผ่ชๅจๅฐ่ฏ fast ๅ expert ่ๅคฉ้ขๅบฆ็ชๅฃใ', descKey: 'config.schema.fields.autoChatModeFallback.desc' }, | |
| { key: 'thinking_summary', label: 'ๆ่็ฒพ็ฎ่พๅบ', labelKey: 'config.schema.fields.thinkingSummary.label', type: 'bool', desc: 'ๅฏ็จๅๅฐๆ่่ฟ็จๆ็ผไธบ็ปๆๅๆ่ฆใๅ ณ้ญๆถ่พๅบๅฎๆด็ๅๅงๆจ็่ฟ็จ๏ผๆฏๆๅค Agent ๆจกๅ็ๅไฝ่ฏฆๆ ไธๅทฅๅ ท่ฐ็จๅฑ็คบใ', descKey: 'config.schema.fields.thinkingSummary.desc' }, | |
| { key: 'dynamic_statsig', label: 'ๅจๆ Statsig', labelKey: 'config.schema.fields.dynamicStatsig.label', type: 'bool', desc: 'ไธบๆฏๆฌก่ฏทๆฑๅจๆ็ๆ Statsig ่ฎพๅคๆ็บน๏ผไปฅ้ไฝ้ฃๆงๆฆๆชๆฆ็ใ', descKey: 'config.schema.fields.dynamicStatsig.desc' }, | |
| { key: 'enable_nsfw', label: 'ๅ ่ฎธ NSFW ็ๆ', labelKey: 'config.schema.fields.enableNsfw.label', type: 'bool', desc: 'ๅ ่ฎธๅพๅ็ๆๆฅๅฃ็ป่ฟ NSFW ๅ ๅฎน่ฟๆปคใ', descKey: 'config.schema.fields.enableNsfw.desc' }, | |
| { key: 'show_search_sources', label: 'ๆญฃๆ่ฟฝๅ ไฟกๆบ', labelKey: 'config.schema.fields.showSearchSources.label', type: 'bool', desc: 'ๆ็ดขไฟกๆบๅง็ปไปฅ search_sources ๅญๆฎต่พๅบใๆญค้้กนๆงๅถๆฏๅฆๅๆถๅจๆญฃๆๆซๅฐพ่ฟฝๅ ## Sources ๆฎต่ฝ๏ผๅ ผๅฎนๆๆฌ่งฃๆๅฎขๆท็ซฏ๏ผใ', descKey: 'config.schema.fields.showSearchSources.desc' }, | |
| { key: 'custom_instruction', label: 'ๅ จๅฑ้ๅ ๆไปค', labelKey: 'config.schema.fields.customInstruction.label', type: 'textarea', desc: 'ไธบๆฏๆฌก่ฏทๆฑๆณจๅ ฅ็ปไธ็ system ๆถๆฏ๏ผ็จไบ็บฆๆๆจกๅ่กไธบๆๅบๅฎ่ง่ฒ่ฎพๅฎใ', descKey: 'config.schema.fields.customInstruction.desc' }, | |
| ] | |
| }, | |
| { | |
| title: 'ๅชไฝ่ฟๅๆ ผๅผ', titleKey: 'config.schema.groups.mediaFormat', | |
| section: 'features', | |
| fields: [ | |
| { | |
| key: 'image_format', | |
| label: 'ๅพ็่ฟๅๆ ผๅผ', | |
| labelKey: 'config.schema.fields.imageFormat.label', | |
| type: 'select', | |
| options: [ | |
| { value: 'grok_url', label: 'URL๏ผGrok ๅ็๏ผ', labelKey: 'config.schema.options.imageFormat.grokUrl' }, | |
| { value: 'local_url', label: 'URL๏ผๆฌๅฐไปฃ็๏ผ', labelKey: 'config.schema.options.imageFormat.localUrl', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ ๅกซๅ APP ่ฎฟ้ฎๅฐๅ', disabledTipKey: 'config.disabledTip.appUrlRequired' }, | |
| { value: 'grok_md', label: 'Markdown๏ผGrok ๅ็๏ผ', labelKey: 'config.schema.options.imageFormat.grokMarkdown' }, | |
| { value: 'local_md', label: 'Markdown๏ผๆฌๅฐไปฃ็๏ผ', labelKey: 'config.schema.options.imageFormat.localMarkdown', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ ๅกซๅ APP ่ฎฟ้ฎๅฐๅ', disabledTipKey: 'config.disabledTip.appUrlRequired' }, | |
| { value: 'base64', label: 'Base64๏ผๅ ๅต๏ผ', labelKey: 'config.schema.options.imageFormat.base64' }, | |
| ], | |
| desc: 'grok_url / grok_md ้ป่ฎค็ดๆฅ่ฟๅ Grok CDN ๅฐๅ๏ผlocal_* ๆจกๅผไผๅ ไธ่ฝฝๅฐๆๅก็ซฏ๏ผๅ้่ฟๆฌๅฐ URL ไปฃ็ๅๅ๏ผbase64 ไผไปฅๅ ๅต Data URI ่ฟๅใๅผๅฏ Imagine Public ๅพ็ไปฃ็ๅ๏ผWebSocket ่ฟๅ็ imagine-public ๅพ็ไนไผๆฌๅฐไปฃ็ใ', | |
| descKey: 'config.schema.fields.imageFormat.desc', | |
| }, | |
| { | |
| key: 'imagine_public_image_proxy', | |
| label: 'Imagine Public ๅพ็ไปฃ็', | |
| labelKey: 'config.schema.fields.imaginePublicImageProxy.label', | |
| type: 'bool', | |
| desc: 'ๅผๅฏๅๅฐ WebSocket ่ฟๅ็ imagine-public ๅพ็ไธ่ฝฝๅฐๆๅก็ซฏ๏ผๅ้่ฟๆฌๅฐ URL ไปฃ็ๅๅ๏ผๅ ณ้ญๆถไฟๆๅ ฌๅผๅพ็็ด่ฟใ', | |
| descKey: 'config.schema.fields.imaginePublicImageProxy.desc', | |
| }, | |
| { | |
| key: 'video_format', | |
| label: '่ง้ข่ฟๅๆ ผๅผ', | |
| labelKey: 'config.schema.fields.videoFormat.label', | |
| type: 'select', | |
| options: [ | |
| { value: 'grok_url', label: 'URL๏ผGrok ๅ็๏ผ', labelKey: 'config.schema.options.videoFormat.grokUrl' }, | |
| { value: 'local_url', label: 'URL๏ผๆฌๅฐไปฃ็๏ผ', labelKey: 'config.schema.options.videoFormat.localUrl', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ ๅกซๅ APP ่ฎฟ้ฎๅฐๅ', disabledTipKey: 'config.disabledTip.appUrlRequired' }, | |
| { value: 'grok_html', label: 'HTML๏ผGrok ๅ็๏ผ', labelKey: 'config.schema.options.videoFormat.grokHtml' }, | |
| { value: 'local_html', label: 'HTML๏ผๆฌๅฐไปฃ็๏ผ', labelKey: 'config.schema.options.videoFormat.localHtml', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ ๅกซๅ APP ่ฎฟ้ฎๅฐๅ', disabledTipKey: 'config.disabledTip.appUrlRequired' }, | |
| ], | |
| desc: 'local_* ๆจกๅผไผๅ ๅฐ่ง้ขไธ่ฝฝ่ณๆๅก็ซฏ๏ผๅ้่ฟๆฌๅฐ URL ไปฃ็ๅๅ๏ผ่ฏท็กฎไฟๅทฒ้ ็ฝฎ APP ่ฎฟ้ฎๅฐๅ๏ผๅนถ่ฏไผฐๅบ็ซๅธฆๅฎฝๆถ่ใ', | |
| descKey: 'config.schema.fields.videoFormat.desc', | |
| }, | |
| ] | |
| }, | |
| ] | |
| }, | |
| { | |
| id: 'schedule', label: 'ไปปๅก่ฐๅบฆ', labelKey: 'config.schema.tabs.schedule', | |
| groups: [ | |
| { | |
| title: '้ ้ขๅทๆฐ่ฐๅบฆ', titleKey: 'config.schema.groups.refreshSchedule', | |
| section: 'account.refresh', | |
| fields: [ | |
| { key: 'enabled', label: 'ๅฏ็จ้ ้ขๅทๆฐ', labelKey: 'config.schema.fields.refreshEnabled.label', type: 'bool', desc: 'ๅผๅฏๅ่ชๅจ่ฟๅ ฅ้ ้ขๅทๆฐๆจกๅผ๏ผๅ ณ้ญๅ่ชๅจ่ฟๅ ฅ่ชๅจ้่ฏๆจกๅผใ', descKey: 'config.schema.fields.refreshEnabled.desc', help: 'ๅผๅฏ๏ผ้ ้ขๅทๆฐๆจกๅผ๏ผscheduler ๅจๆๅๆญฅ็ๅฎ้ ้ข๏ผ้ๅทๆ่ฏๅใ\nๅ ณ้ญ๏ผ่ชๅจ้่ฏๆจกๅผ๏ผไธไธปๅจๆขๆต upstream๏ผ้ๅท้ๆบ๏ผๅบ้่ชๅจๆขๅท้่ฏๆๅค 5 ๆฌกใ\nๅปบ่ฎฎ๏ผไธ็บงไปฅไธ่ดฆๅทๅ ณ้ญ๏ผ้ฟๅ ไธปๅจๆขๆต่งฆๅ upstream 429ใ', helpKey: 'config.schema.fields.refreshEnabled.help' }, | |
| { key: 'basic_interval_sec', label: 'Basic ๅจๆ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.basicInterval.label', type: 'number', desc: 'basic ๅทๆฑ ๅ ฑ็จๅจๆ๏ผquota ๆจกๅผไธ็จไบๅๅฐๅทๆฐ๏ผrandom ๆจกๅผไธ็จไบ 429 ๅทๅดใ้ป่ฎค 86400sใ', descKey: 'config.schema.fields.basicInterval.desc' }, | |
| { key: 'super_interval_sec', label: 'Super ๅจๆ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.superInterval.label', type: 'number', desc: 'super ๅทๆฑ ๅ ฑ็จๅจๆ๏ผquota ๆจกๅผไธ็จไบๅๅฐๅทๆฐ๏ผrandom ๆจกๅผไธ็จไบ 429 ๅทๅดใ้ป่ฎค 7200sใ', descKey: 'config.schema.fields.superInterval.desc' }, | |
| { key: 'heavy_interval_sec', label: 'Heavy ๅจๆ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.heavyInterval.label', type: 'number', desc: 'heavy ๅทๆฑ ๅ ฑ็จๅจๆ๏ผquota ๆจกๅผไธ็จไบๅๅฐๅทๆฐ๏ผrandom ๆจกๅผไธ็จไบ 429 ๅทๅดใ้ป่ฎค 7200sใ', descKey: 'config.schema.fields.heavyInterval.desc' }, | |
| { key: 'on_demand_min_interval_sec', label: 'ๆ้ๅทๆฐ้ด้๏ผ็ง๏ผ', labelKey: 'config.schema.fields.onDemandMinInterval.label', type: 'number', desc: '่ฏทๆฑ้พ่ทฏ่งฆๅๅทๆฐ๏ผไพๅฆๆถๅฐ 429๏ผๆถ็่ๆต้ด้ใN ็งๅ ้ๅค่งฆๅๅชๆง่กไธๆฌก๏ผ้ฟๅ ๆน้ๆ็้ ้ขๆฅๅฃใ', descKey: 'config.schema.fields.onDemandMinInterval.desc' }, | |
| { key: 'usage_concurrency', label: 'ๅทๆฐๅนถๅๆฐ', labelKey: 'config.schema.fields.usageConcurrency.label', type: 'number', desc: 'ๅนถๅ่ฐ็จ usage ๆฅๅฃๅทๆฐ้ขๅบฆๆถๅ ่ฎธ็ๆๅคงๅนถ่กๆฐใ', descKey: 'config.schema.fields.usageConcurrency.desc' }, | |
| ] | |
| }, | |
| { | |
| title: '้ๅทๅนถๅ้ๅถ', titleKey: 'config.schema.groups.selection', | |
| section: 'account.selection', | |
| fields: [ | |
| { key: 'max_inflight', label: 'ๅๅทๅนถๅไธ้', labelKey: 'config.schema.fields.maxInflight.label', type: 'number', desc: 'ๅไธช่ดฆๅทๅๆถๅจๆง่ก็่ฏทๆฑไธ้๏ผ่ถ ่ฟๅ้ๅท่ทฏๅพ่ทณ่ฟ่ฏฅๅท๏ผไธค็งๆจกๅผๅ ฑ็จ๏ผใ', descKey: 'config.schema.fields.maxInflight.desc' }, | |
| ] | |
| }, | |
| { | |
| title: 'ๆน้ๆไฝๅนถๅ', titleKey: 'config.schema.groups.batchConcurrency', | |
| section: 'batch', | |
| fields: [ | |
| { key: 'nsfw_concurrency', label: 'ๅผๅฏ NSFW ๅนถๅๆฐ', labelKey: 'config.schema.fields.nsfwConcurrency.label', type: 'number', min: 1, max: 80, desc: 'ๆน้ๅผๅฏ NSFW ๆถ็ token ็บงๅนถๅๆฐ๏ผๆฏไธช token ้็ปๅไธๆญฅ็ฝ็ป่ฏทๆฑใ', descKey: 'config.schema.fields.nsfwConcurrency.desc' }, | |
| { key: 'refresh_concurrency', label: 'ๅทๆฐ Usage ๅนถๅๆฐ', labelKey: 'config.schema.fields.refreshConcurrency.label', type: 'number', min: 1, max: 80, desc: 'ๆน้ๅทๆฐ่ดฆๆท้ขๅบฆๆถ็ token ็บงๅนถๅๆฐใ', descKey: 'config.schema.fields.refreshConcurrency.desc' }, | |
| { key: 'asset_upload_concurrency', label: 'ไธไผ Asset ๅนถๅๆฐ', labelKey: 'config.schema.fields.assetUploadConcurrency.label', type: 'number', desc: 'API ๆฅๆถๆไปถ้ไปถๅ่งฆๅ็ Asset ไธไผ ไปปๅก๏ผๅ จๅฑๆๅคงๅนถๅๆฐ๏ผ่ทจๆๆ่ฏทๆฑๅ ฑไบซใ', descKey: 'config.schema.fields.assetUploadConcurrency.desc' }, | |
| { key: 'asset_list_concurrency', label: 'ๆฅ่ฏข Asset ๅนถๅๆฐ', labelKey: 'config.schema.fields.assetListConcurrency.label', type: 'number', desc: 'list_assets ๆฅๅฃ็ๅ จๅฑๆๅคงๅนถๅๆฐ๏ผ่ทจๆๆ่ฏทๆฑๅ ฑไบซใ', descKey: 'config.schema.fields.assetListConcurrency.desc' }, | |
| { key: 'asset_delete_concurrency', label: 'ๅ ้ค Asset ๅนถๅๆฐ', labelKey: 'config.schema.fields.assetDeleteConcurrency.label', type: 'number', desc: 'delete_asset ๆฅๅฃ็ๅ จๅฑๆๅคงๅนถๅๆฐ๏ผๅๆถไฝไธบ็ฎก็ๅๅฐๆน้ๆธ ็็ token ็บง้ป่ฎคๅนถๅๅผใ', descKey: 'config.schema.fields.assetDeleteConcurrency.desc' }, | |
| ] | |
| }, | |
| ] | |
| }, | |
| { | |
| id: 'proxy', label: '็ฝ็ปไปฃ็', labelKey: 'config.schema.tabs.proxy', | |
| groups: [ | |
| { | |
| title: 'ๅบ็ซไปฃ็่ฎพ็ฝฎ', titleKey: 'config.schema.groups.egressProxy', | |
| section: 'proxy.egress', | |
| fields: [ | |
| { | |
| key: 'mode', | |
| label: 'ไปฃ็ๆจกๅผ', | |
| labelKey: 'config.schema.fields.proxyMode.label', | |
| type: 'select', | |
| options: [ | |
| { value: 'direct', label: '็ด่ฟ', labelKey: 'config.schema.options.proxyMode.direct' }, | |
| { value: 'single_proxy', label: 'ไปฃ็๏ผๅบๅฎ๏ผ', labelKey: 'config.schema.options.proxyMode.singleProxy' }, | |
| { value: 'proxy_pool', label: 'ไปฃ็๏ผ่ฝฎ่ฝฌ๏ผ', labelKey: 'config.schema.options.proxyMode.proxyPool' }, | |
| ], | |
| desc: 'ๆงๅถๅบ็ซๆต้ไปฃ็็ญ็ฅใdirect ไธบ็ด่ฟ๏ผsingle_proxy ไฝฟ็จๅบๅฎไปฃ็๏ผproxy_pool ๅจไปฃ็ๆฑ ไธญ่ฝฎ่ฝฌใ', | |
| descKey: 'config.schema.fields.proxyMode.desc', | |
| }, | |
| { | |
| key: 'proxy_url', | |
| label: 'ๅบ็กไปฃ็ URL', | |
| labelKey: 'config.schema.fields.proxyUrl.label', | |
| type: 'text', | |
| wide: true, | |
| desc: 'API ๆต้็ๅบ็ซไปฃ็ๅฐๅ๏ผๆฏๆ httpใhttpsใsocks5 ๅ่ฎฎใ', | |
| descKey: 'config.schema.fields.proxyUrl.desc', | |
| showIf: { path: 'proxy.egress.mode', values: ['single_proxy'] }, | |
| }, | |
| { | |
| key: 'resource_proxy_url', | |
| label: '่ตๆบไปฃ็ URL', | |
| labelKey: 'config.schema.fields.resourceProxyUrl.label', | |
| type: 'text', | |
| wide: true, | |
| desc: 'ๅพ็ใ่ง้ข็ญๅชไฝ่ตๆบไธ่ฝฝ็ไธ็จไปฃ็ๅฐๅ๏ผ็็ฉบๆถๅ่ฝ่ณๅบ็กไปฃ็ใ', | |
| descKey: 'config.schema.fields.resourceProxyUrl.desc', | |
| showIf: { path: 'proxy.egress.mode', values: ['single_proxy'] }, | |
| }, | |
| { | |
| key: 'proxy_pool', | |
| label: 'ๅบ็กไปฃ็ๆฑ ', | |
| labelKey: 'config.schema.fields.proxyPool.label', | |
| type: 'textarea', | |
| desc: 'API ๆต้ไปฃ็ๆฑ ๏ผๆฏ่กไธไธชไปฃ็ URL๏ผ่ฏทๆฑๆถ่ฝฎ่ฝฌไฝฟ็จใ', | |
| descKey: 'config.schema.fields.proxyPool.desc', | |
| showIf: { path: 'proxy.egress.mode', values: ['proxy_pool'] }, | |
| }, | |
| { | |
| key: 'resource_proxy_pool', | |
| label: '่ตๆบไปฃ็ๆฑ ', | |
| labelKey: 'config.schema.fields.resourceProxyPool.label', | |
| type: 'textarea', | |
| desc: 'ๅชไฝ่ตๆบไธ่ฝฝไธ็จไปฃ็ๆฑ ๏ผๆฏ่กไธไธชไปฃ็ URL๏ผ็็ฉบๆถๅ่ฝ่ณๅบ็กไปฃ็ๆฑ ใ', | |
| descKey: 'config.schema.fields.resourceProxyPool.desc', | |
| showIf: { path: 'proxy.egress.mode', values: ['proxy_pool'] }, | |
| }, | |
| { | |
| key: 'skip_ssl_verify', | |
| label: '่ทณ่ฟ SSL ๆ ก้ช', | |
| labelKey: 'config.schema.fields.skipSslVerify.label', | |
| type: 'bool', | |
| desc: '่ทณ่ฟไปฃ็ๆๅกๅจ TLS ่ฏไนฆๆ ก้ช๏ผไป ๅจไฝฟ็จ่ช็ญพๅ่ฏไนฆ็ไปฃ็ๆถๅฏ็จใ', | |
| descKey: 'config.schema.fields.skipSslVerify.desc', | |
| showIf: { path: 'proxy.egress.mode', values: ['single_proxy', 'proxy_pool'] }, | |
| }, | |
| ] | |
| }, | |
| { | |
| title: 'Cloudflare Clearance', titleKey: 'config.schema.groups.clearance', | |
| section: 'proxy.clearance', | |
| fields: [ | |
| { | |
| key: 'mode', | |
| label: 'Clearance ๆ กๅ', | |
| labelKey: 'config.schema.fields.clearanceMode.label', | |
| type: 'select', | |
| options: [ | |
| { value: 'none', label: 'ๅ ณ้ญ', labelKey: 'config.schema.options.clearanceMode.none' }, | |
| { value: 'manual', label: 'ๆๅจ', labelKey: 'config.schema.options.clearanceMode.manual' }, | |
| { value: 'flaresolverr', label: 'FlareSolverr', labelKey: 'config.schema.options.clearanceMode.flaresolverr' }, | |
| ], | |
| desc: 'ๆงๅถ Cloudflare Clearance ็่ทๅๆนๅผใ', | |
| descKey: 'config.schema.fields.clearanceMode.desc', | |
| }, | |
| { | |
| key: 'browser', | |
| label: 'ๆต่งๅจๆ็บน', | |
| labelKey: 'config.schema.fields.browserFingerprint.label', | |
| type: 'text', | |
| desc: 'curl_cffi ๆต่งๅจๆ็บนๆ ่ฏ๏ผ้ไธ Cloudflare ๆๆ็ JA3 ๆ็บนๅน้ ๏ผไพๅฆ chrome136ใ', | |
| descKey: 'config.schema.fields.browserFingerprint.desc', | |
| showIf: { path: 'proxy.clearance.mode', values: ['manual', 'flaresolverr'] }, | |
| }, | |
| { | |
| key: 'user_agent', | |
| label: 'User-Agent', | |
| labelKey: 'config.schema.fields.userAgent.label', | |
| type: 'text', | |
| wide: true, | |
| desc: '่ฏทๆฑๅคดไธญ็ User-Agent๏ผ้ไธ Cloudflare ๆ ก้ช Cookie ๆถๆไฝฟ็จ็ๆต่งๅจไฟๆไธ่ดใ', | |
| descKey: 'config.schema.fields.userAgent.desc', | |
| showIf: { path: 'proxy.clearance.mode', values: ['manual'] }, | |
| }, | |
| { | |
| key: 'cf_cookies', | |
| label: 'Cloudflare Cookies', | |
| labelKey: 'config.schema.fields.cfCookies.label', | |
| type: 'text', | |
| wide: true, | |
| desc: 'Cloudflare ไธๅ็ๅฎๆด Cookie ๅญ็ฌฆไธฒ๏ผๅบๅ ๅซ cf_clearance ็ญๅญๆฎตใ', | |
| descKey: 'config.schema.fields.cfCookies.desc', | |
| showIf: { path: 'proxy.clearance.mode', values: ['manual'] }, | |
| }, | |
| { | |
| key: 'flaresolverr_url', | |
| label: 'FlareSolverr URL', | |
| labelKey: 'config.schema.fields.flaresolverrUrl.label', | |
| type: 'text', | |
| wide: true, | |
| desc: 'FlareSolverr ๅฎไพ็ HTTP ๆๅกๅฐๅใ', | |
| descKey: 'config.schema.fields.flaresolverrUrl.desc', | |
| showIf: { path: 'proxy.clearance.mode', values: ['flaresolverr'] }, | |
| }, | |
| { | |
| key: 'timeout_sec', | |
| label: 'ๆๆ่ถ ๆถ๏ผ็ง๏ผ', | |
| labelKey: 'config.schema.fields.challengeTimeout.label', | |
| type: 'number', | |
| desc: 'FlareSolverr ๅฎๆ Cloudflare ๆๆๅ ่ฎธ็ๆ้ฟ็ญๅพ ๆถ้ฟใ', | |
| descKey: 'config.schema.fields.challengeTimeout.desc', | |
| showIf: { path: 'proxy.clearance.mode', values: ['flaresolverr'] }, | |
| }, | |
| { | |
| key: 'refresh_interval', | |
| label: 'ๅทๆฐ้ด้๏ผ็ง๏ผ', | |
| labelKey: 'config.schema.fields.refreshInterval.label', | |
| type: 'number', | |
| desc: 'Clearance Cookie ้ข็ญๅทๆฐไปปๅก็่ฐๅบฆ้ด้ใ', | |
| descKey: 'config.schema.fields.refreshInterval.desc', | |
| showIf: { path: 'proxy.clearance.mode', values: ['flaresolverr'] }, | |
| }, | |
| ] | |
| }, | |
| { | |
| title: '้่ฏ็ญ็ฅ', titleKey: 'config.schema.groups.retry', | |
| section: 'retry', | |
| fields: [ | |
| { key: 'max_retries', label: 'ๅบ็จๅฑ้่ฏไธ้', labelKey: 'config.schema.fields.maxRetries.label', type: 'number', desc: '่ฏทๆฑๅคฑ่ดฅๅ็ๅบ็จๅฑๆๅคง้่ฏๆฌกๆฐ๏ผ0 ่กจ็คบไธ้่ฏใ้็จไบ chat.completions ไธ responses ็ซฏ็นใ', descKey: 'config.schema.fields.maxRetries.desc' }, | |
| { key: 'on_codes', label: 'ๅบ็จๅฑ้่ฏ่งฆๅ็ถๆ็ ', labelKey: 'config.schema.fields.retryCodes.label', type: 'text', desc: 'ๆถๅฐๆๅฎ HTTP ็ถๆ็ ๆถ่งฆๅๅบ็จๅฑ้่ฏ๏ผๅคไธชๅผ่ฏทไฝฟ็จ่ฑๆ้ๅทๅ้๏ผไพๅฆ 429,503ใ', descKey: 'config.schema.fields.retryCodes.desc' }, | |
| { key: 'reset_session_status_codes', label: 'ไผ ่พๅฑไผ่ฏ้ๅปบ็ถๆ็ ', labelKey: 'config.schema.fields.resetSessionCodes.label', type: 'text', desc: 'ๆถๅฐๆๅฎ HTTP ็ถๆ็ ๆถ่ชๅจ้ๅปบไผ ่พๅฑไปฃ็ไผ่ฏ๏ผTransport Session๏ผ๏ผๅคไธชๅผ่ฏทไฝฟ็จ่ฑๆ้ๅทๅ้ใ', descKey: 'config.schema.fields.resetSessionCodes.desc' }, | |
| ] | |
| }, | |
| { | |
| title: '่ฏทๆฑ่ถ ๆถ่ฎพ็ฝฎ', titleKey: 'config.schema.groups.timeouts', | |
| section: 'chat', | |
| fields: [ | |
| { key: 'timeout', section: 'chat', label: 'ๅฏน่ฏ่ฏทๆฑ่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.chatTimeout.label', type: 'number', desc: 'ๅฏน่ฏไธๅพๅ็ผ่พ่ฏทๆฑ็ HTTP ่ฟๆฅ่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.chatTimeout.desc' }, | |
| { key: 'timeout', section: 'image', label: 'ๅพๅ่ฏทๆฑ่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.imageTimeout.label', type: 'number', desc: 'ๅพๅ็ๆ WebSocket ่ฏทๆฑ็ๆป่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.imageTimeout.desc' }, | |
| { key: 'stream_timeout', section: 'image', label: 'ๅพๅๆตๅผ็ฉบ้ฒ่ถ ๆถ๏ผ็ง๏ผ', type: 'number', desc: 'ๅพๅ็ๆ WebSocket ๅจๆตๅผๆฅๆถ้ถๆฎตๅ ่ฎธ็ๆ้ฟ่ฟ็ปญ็ฉบ้ฒ็ญๅพ ๆถ้ฟใ' }, | |
| { key: 'timeout', section: 'video', label: '่ง้ข่ฏทๆฑ่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.videoTimeout.label', type: 'number', desc: '่ง้ข็ๆ่ฏทๆฑ็็ปไธ่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.videoTimeout.desc' }, | |
| { key: 'timeout', section: 'voice', label: '่ฏญ้ณ่ฏทๆฑ่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.voiceTimeout.label', type: 'number', desc: '่ฏญ้ณ่ฏทๆฑ็็ปไธ่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.voiceTimeout.desc' }, | |
| { key: 'timeout', section: 'nsfw', label: 'NSFW ๅค็่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.nsfwTimeout.label', type: 'number', desc: 'set_birth_date ไธ enable_nsfw ๆฅๅฃ่ฐ็จ็่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.nsfwTimeout.desc' }, | |
| { key: 'upload_timeout', section: 'asset', label: '่ตไบงไธไผ ่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.assetUploadTimeout.label', type: 'number', desc: 'Asset ไธไผ ๆฅๅฃ็่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.assetUploadTimeout.desc' }, | |
| { key: 'download_timeout', section: 'asset', label: '่ตไบงไธ่ฝฝ่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.assetDownloadTimeout.label', type: 'number', desc: 'Asset ไธ่ฝฝๆฅๅฃ็่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.assetDownloadTimeout.desc' }, | |
| { key: 'list_timeout', section: 'asset', label: '่ตไบงๆฅ่ฏข่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.assetListTimeout.label', type: 'number', desc: 'Asset ๆฅ่ฏขๆฅๅฃ็่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.assetListTimeout.desc' }, | |
| { key: 'delete_timeout', section: 'asset', label: '่ตไบงๅ ้ค่ถ ๆถ๏ผ็ง๏ผ', labelKey: 'config.schema.fields.assetDeleteTimeout.label', type: 'number', desc: 'Asset ๅ ้คๆฅๅฃ็่ถ ๆถๆถ้ดใ', descKey: 'config.schema.fields.assetDeleteTimeout.desc' }, | |
| ] | |
| }, | |
| ] | |
| }, | |
| { | |
| id: 'cache', label: '็ผๅญๅญๅจ', labelKey: 'config.schema.tabs.cache', | |
| groups: [ | |
| { | |
| title: 'ๆฌๅฐๅชไฝ็ผๅญ', titleKey: 'config.schema.groups.localMediaCache', | |
| section: 'cache.local', | |
| fields: [ | |
| { | |
| key: 'image_max_mb', | |
| label: 'ๅพ็็ผๅญไธ้๏ผMB๏ผ', | |
| labelKey: 'config.schema.fields.imageCacheMaxMb.label', | |
| type: 'number', | |
| desc: '0 ่กจ็คบไธ้ๅถใไป ๅฏนๆฌๅฐไปฃ็่ฝ็็ๆ๏ผ่ถ ้ๅๆๆๆงๆไปถไผๅ ๆธ ็๏ผๅนถไธๆฌกๆงๅ่ฝๅฐไธ้็ 60%ใ', | |
| descKey: 'config.schema.fields.imageCacheMaxMb.desc', | |
| }, | |
| { | |
| key: 'video_max_mb', | |
| label: '่ง้ข็ผๅญไธ้๏ผMB๏ผ', | |
| labelKey: 'config.schema.fields.videoCacheMaxMb.label', | |
| type: 'number', | |
| desc: '0 ่กจ็คบไธ้ๅถใไป ๅฏนๆฌๅฐไปฃ็่ฝ็็ๆ๏ผ่ถ ้ๅๆๆๆงๆไปถไผๅ ๆธ ็๏ผๅนถไธๆฌกๆงๅ่ฝๅฐไธ้็ 60%ใ', | |
| descKey: 'config.schema.fields.videoCacheMaxMb.desc', | |
| }, | |
| ] | |
| }, | |
| ] | |
| }, | |
| ]; | |
| // โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| let SCHEMA = []; | |
| let _cfg = {}; // loaded config (nested) | |
| let _dirty = {}; // { "section.key": newValue } | |
| let _activeTab = SCHEMA_DEF[0].id; | |
| let _configLoaded = false; | |
| let _rowRefs = new Map(); | |
| let _tabButtons = new Map(); | |
| let _tabDirtyCounts = new Map(); | |
| let _tabFieldIndex = new Map(); | |
| let _conditionalRows = []; | |
| let _selectRefs = []; | |
| let _showIfDeps = new Map(); | |
| let _disabledSelectDeps = new Map(); | |
| function tr(key, params, fallback) { | |
| if (!key) return fallback ?? ''; | |
| const value = t(key, params); | |
| return value === key ? (fallback ?? key) : value; | |
| } | |
| function waitI18n() { | |
| return new Promise((resolve) => I18n.onReady(resolve)); | |
| } | |
| function _addDependency(depMap, path, target) { | |
| if (!path || !target) return; | |
| if (!depMap.has(path)) depMap.set(path, []); | |
| depMap.get(path).push(target); | |
| } | |
| function _showIfPaths(showIf) { | |
| if (!showIf) return []; | |
| return [typeof showIf === 'string' ? showIf : showIf.path].filter(Boolean); | |
| } | |
| function _disabledWhenPaths(condition) { | |
| if (condition === 'no_app_url') return ['app.app_url']; | |
| return []; | |
| } | |
| function _collectDependentTargets(paths, depMap) { | |
| if (!Array.isArray(paths) || !paths.length) return []; | |
| const seen = new Set(); | |
| const items = []; | |
| paths.forEach((path) => { | |
| (depMap.get(path) || []).forEach((target) => { | |
| if (seen.has(target)) return; | |
| seen.add(target); | |
| items.push(target); | |
| }); | |
| }); | |
| return items; | |
| } | |
| function _syncTabDirtyState(tabId) { | |
| const btn = _tabButtons.get(tabId); | |
| if (!btn) return; | |
| btn.classList.toggle('dirty', (_tabDirtyCounts.get(tabId) || 0) > 0); | |
| } | |
| function _updateDirtyCount(fp, delta) { | |
| const tabId = _tabFieldIndex.get(fp); | |
| if (!tabId || !delta) return; | |
| const next = Math.max(0, (_tabDirtyCounts.get(tabId) || 0) + delta); | |
| _tabDirtyCounts.set(tabId, next); | |
| _syncTabDirtyState(tabId); | |
| } | |
| function _resetRenderCaches() { | |
| _rowRefs = new Map(); | |
| _tabButtons = new Map(); | |
| _tabDirtyCounts = new Map(); | |
| _tabFieldIndex = new Map(); | |
| _conditionalRows = []; | |
| _selectRefs = []; | |
| _showIfDeps = new Map(); | |
| _disabledSelectDeps = new Map(); | |
| } | |
| function _localizeOption(opt) { | |
| if (typeof opt !== 'object') return opt; | |
| return { | |
| ...opt, | |
| label: tr(opt.labelKey, null, opt.label ?? opt.value), | |
| ...(opt.disabledTipKey ? { disabledTip: tr(opt.disabledTipKey, null, opt.disabledTip ?? '') } : {}), | |
| }; | |
| } | |
| function _localizeField(field) { | |
| return { | |
| ...field, | |
| label: field.labelKey ? tr(field.labelKey, null, field.label ?? field.key) : (field.label ?? field.key), | |
| desc: field.descKey ? tr(field.descKey, null, field.desc ?? '') : (field.desc ?? ''), | |
| help: field.helpKey ? tr(field.helpKey, null, field.help ?? '') : (field.help ?? ''), | |
| options: field.options ? field.options.map(_localizeOption) : field.options, | |
| }; | |
| } | |
| function buildSchema() { | |
| return SCHEMA_DEF.map((tab) => ({ | |
| ...tab, | |
| label: tr(tab.labelKey, null, tab.label ?? tab.id), | |
| groups: tab.groups.map((group) => ({ | |
| ...group, | |
| title: group.titleKey ? tr(group.titleKey, null, group.title ?? '') : (group.title ?? ''), | |
| fields: group.fields.map(_localizeField), | |
| })), | |
| })); | |
| } | |
| function applyConfigI18n() { | |
| document.title = tr('config.pageTitle', null, 'Grok2API - ้ ็ฝฎ็ฎก็'); | |
| SCHEMA = buildSchema(); | |
| if (!_configLoaded) return; | |
| renderAll(); | |
| _refreshDirtyState({ autoNormalizeSelects: false }); | |
| } | |
| // โโ API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function _api(method, path, body) { | |
| const key = await adminKey.get(); | |
| const r = await fetch(ADMIN_API + path, { | |
| method, | |
| headers: { ...(body != null && { 'Content-Type': 'application/json' }), Authorization: `Bearer ${key}` }, | |
| ...(body != null && { body: JSON.stringify(body) }), | |
| }); | |
| if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || r.status); } | |
| return r.json(); | |
| } | |
| // โโ Schema helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // Navigate a nested object by dotted path. Returns undefined if any segment is missing. | |
| function _getByPath(obj, dottedPath) { | |
| return dottedPath.split('.').reduce((node, k) => | |
| (node != null && typeof node === 'object') ? node[k] : undefined, obj); | |
| } | |
| // Set a value at a dotted path, creating intermediate objects as needed. | |
| function _setByPath(obj, dottedPath, value) { | |
| const keys = dottedPath.split('.'); | |
| let cur = obj; | |
| for (let i = 0; i < keys.length - 1; i++) { | |
| if (cur[keys[i]] == null || typeof cur[keys[i]] !== 'object') cur[keys[i]] = {}; | |
| cur = cur[keys[i]]; | |
| } | |
| cur[keys[keys.length - 1]] = value; | |
| } | |
| // Evaluate a disabledWhen condition on a select option. | |
| // Currently supported: 'no_app_url' โ true when app.app_url is empty. | |
| function _evalDisabledWhen(condition) { | |
| if (condition === 'no_app_url') { | |
| const v = 'app.app_url' in _dirty ? _dirty['app.app_url'] : _getByPath(_cfg, 'app.app_url'); | |
| return !v || String(v).trim() === ''; | |
| } | |
| return false; | |
| } | |
| // Evaluate a showIf condition. | |
| // String form: truthy check on config path. "app.webui_enabled" | |
| // Object form: value-in-list check. { path: "proxy.egress.mode", values: ["single_proxy"] } | |
| function _evalShowIf(showIf) { | |
| if (!showIf) return true; | |
| const path = typeof showIf === 'string' ? showIf : showIf.path; | |
| const cur = path in _dirty ? _dirty[path] : _getByPath(_cfg, path); | |
| if (typeof showIf === 'string') return !!cur; | |
| return showIf.values.includes(cur); | |
| } | |
| // Full dotted config path for a field: "proxy.egress.mode" | |
| function _fullPath(section, key) { return `${section}.${key}`; } | |
| // Get the current value of a field from loaded config. | |
| function _getValue(section, key, field) { | |
| const value = _getByPath(_cfg, _fullPath(section, key)); | |
| return value === undefined && field && Object.prototype.hasOwnProperty.call(field, 'default') | |
| ? field.default | |
| : value; | |
| } | |
| function _getCurrentValue(section, key, field) { | |
| const fp = _fullPath(section, key); | |
| return fp in _dirty ? _dirty[fp] : _getValue(section, key, field); | |
| } | |
| // โโ Render โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function _el(tag, attrs, ...children) { | |
| const el = document.createElement(tag); | |
| Object.entries(attrs || {}).forEach(([k, v]) => { | |
| if (k === 'class') el.className = v; | |
| else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); | |
| else el.setAttribute(k, v); | |
| }); | |
| children.flat().forEach(c => { | |
| if (c == null) return; | |
| el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); | |
| }); | |
| return el; | |
| } | |
| let _helpTipEl = null; | |
| let _activeHelpButton = null; | |
| function _hideHelpTip() { | |
| if (_activeHelpButton) _activeHelpButton.removeAttribute('aria-describedby'); | |
| if (_helpTipEl) _helpTipEl.remove(); | |
| _helpTipEl = null; | |
| _activeHelpButton = null; | |
| } | |
| function _positionHelpTip(button, tip) { | |
| const margin = 12; | |
| const rect = button.getBoundingClientRect(); | |
| const viewportWidth = window.innerWidth || document.documentElement.clientWidth; | |
| const viewportHeight = window.innerHeight || document.documentElement.clientHeight; | |
| const maxWidth = Math.max(120, viewportWidth - margin * 2); | |
| tip.style.maxWidth = `${Math.min(320, maxWidth)}px`; | |
| tip.style.left = '0px'; | |
| tip.style.top = '0px'; | |
| const tipRect = tip.getBoundingClientRect(); | |
| let left = rect.left + rect.width / 2 - tipRect.width / 2; | |
| left = Math.max(margin, Math.min(left, viewportWidth - tipRect.width - margin)); | |
| let placement = 'top'; | |
| let top = rect.top - tipRect.height - 8; | |
| if (top < margin) { | |
| placement = 'bottom'; | |
| top = rect.bottom + 8; | |
| } | |
| if (top + tipRect.height > viewportHeight - margin) { | |
| top = Math.max(margin, viewportHeight - tipRect.height - margin); | |
| } | |
| const arrowLeft = Math.max(12, Math.min(rect.left + rect.width / 2 - left, tipRect.width - 12)); | |
| tip.dataset.placement = placement; | |
| tip.style.setProperty('--arrow-left', `${arrowLeft}px`); | |
| tip.style.left = `${left}px`; | |
| tip.style.top = `${top}px`; | |
| } | |
| function _showHelpTip(button) { | |
| const text = button.getAttribute('data-tip'); | |
| if (!text) return; | |
| _hideHelpTip(); | |
| const tip = _el('div', { | |
| class: 'cfg-help-tip', | |
| id: 'cfg-help-tip', | |
| role: 'tooltip', | |
| }, text); | |
| document.body.appendChild(tip); | |
| button.setAttribute('aria-describedby', 'cfg-help-tip'); | |
| _helpTipEl = tip; | |
| _activeHelpButton = button; | |
| _positionHelpTip(button, tip); | |
| requestAnimationFrame(() => tip.classList.add('visible')); | |
| } | |
| window.addEventListener('resize', _hideHelpTip); | |
| window.addEventListener('scroll', _hideHelpTip, true); | |
| document.addEventListener('keydown', (event) => { | |
| if (event.key === 'Escape') _hideHelpTip(); | |
| }); | |
| document.addEventListener('click', (event) => { | |
| if (!event.target.closest?.('.cfg-help')) _hideHelpTip(); | |
| }); | |
| function renderInput(field, section, value) { | |
| const fp = _fullPath(section, field.key); | |
| const onChange = (v) => { | |
| const wasDirty = fp in _dirty; | |
| const orig = _getValue(section, field.key, field); | |
| const same = String(orig) === String(v) || orig === v; | |
| if (same) delete _dirty[fp]; else _dirty[fp] = v; | |
| const isDirty = fp in _dirty; | |
| if (wasDirty !== isDirty) _updateDirtyCount(fp, isDirty ? 1 : -1); | |
| _refreshRow(section, field.key); | |
| _refreshDirtyState({ changedPaths: [fp] }); | |
| }; | |
| if (field.type === 'bool') { | |
| const chk = document.createElement('input'); | |
| chk.type = 'checkbox'; | |
| chk.checked = Boolean(value); | |
| chk.id = `field-${section}-${field.key}`; | |
| chk.addEventListener('change', () => onChange(chk.checked)); | |
| const track = _el('span', { class: 'toggle-track' }); | |
| const label = _el('label', { class: 'toggle', for: chk.id }, chk, track); | |
| return label; | |
| } | |
| if (field.type === 'select') { | |
| const sel = _el('select', { class: 'cfg-select', id: `field-${section}-${field.key}` }); | |
| const deps = new Set(); | |
| (field.options || []).forEach(opt => { | |
| const o = document.createElement('option'); | |
| o.value = (typeof opt === 'object') ? opt.value : opt; | |
| o.textContent = (typeof opt === 'object') ? opt.label : opt; | |
| if (o.value === value) o.selected = true; | |
| if (typeof opt === 'object' && opt.disabledWhen) { | |
| o.dataset.disabledWhen = opt.disabledWhen; | |
| if (opt.disabledTip) o.dataset.disabledTip = opt.disabledTip; | |
| const isDisabled = _evalDisabledWhen(opt.disabledWhen); | |
| o.disabled = isDisabled; | |
| if (isDisabled && opt.disabledTip) o.title = opt.disabledTip; | |
| _disabledWhenPaths(opt.disabledWhen).forEach((path) => deps.add(path)); | |
| } | |
| sel.appendChild(o); | |
| }); | |
| if (deps.size) { | |
| _selectRefs.push(sel); | |
| deps.forEach((path) => _addDependency(_disabledSelectDeps, path, sel)); | |
| } | |
| sel.addEventListener('change', () => onChange(sel.value)); | |
| return sel; | |
| } | |
| if (field.type === 'textarea') { | |
| const ta = _el('textarea', { class: 'cfg-textarea', id: `field-${section}-${field.key}`, | |
| rows: '3', placeholder: '' }); | |
| ta.value = value ?? ''; | |
| ta.addEventListener('input', () => onChange(ta.value)); | |
| return ta; | |
| } | |
| if (field.type === 'number') { | |
| const attrs = { class: 'cfg-number', type: 'number', | |
| id: `field-${section}-${field.key}`, value: value ?? '' }; | |
| if (field.min != null) attrs.min = field.min; | |
| if (field.max != null) attrs.max = field.max; | |
| const inp = _el('input', attrs); | |
| inp.addEventListener('input', () => { | |
| let n = inp.value === '' ? '' : Number(inp.value); | |
| // ๅผบๅถ้ๅถๅจ min/max ่ๅดๅ | |
| if (typeof n === 'number' && !isNaN(n)) { | |
| if (field.min != null && n < field.min) n = field.min; | |
| if (field.max != null && n > field.max) n = field.max; | |
| if (inp.value !== String(n)) inp.value = n; | |
| } | |
| onChange(n); | |
| }); | |
| return inp; | |
| } | |
| // text / password | |
| const inp = _el('input', { | |
| class: `cfg-text${field.wide ? ' wide' : ''}`, | |
| type: field.type === 'password' ? 'password' : 'text', | |
| id: `field-${section}-${field.key}`, | |
| value: value ?? '', | |
| autocomplete: 'off', | |
| }); | |
| inp.addEventListener('input', () => onChange(inp.value)); | |
| if (field.rand) { | |
| const btn = _el('button', { class: 'cfg-rand-btn', type: 'button', title: tr('config.randomize', null, '้ๆบ็ๆ'), onclick: () => { | |
| const uuid = crypto.randomUUID().replace(/-/g, '').slice(0, 24); | |
| inp.value = uuid; | |
| inp.type = 'text'; | |
| onChange(uuid); | |
| }}); | |
| btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="3"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/><circle cx="16" cy="8" r="1.5" fill="currentColor" stroke="none"/><circle cx="8" cy="16" r="1.5" fill="currentColor" stroke="none"/><circle cx="16" cy="16" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg>'; | |
| const wrap = _el('div', { style: 'display:flex;align-items:center;gap:6px;flex:1;min-width:0' }, inp, btn); | |
| return wrap; | |
| } | |
| return inp; | |
| } | |
| function renderRow(field, section, tabId) { | |
| const fp = _fullPath(section, field.key); | |
| const value = _getValue(section, field.key, field); | |
| const isDirty = fp in _dirty; | |
| const dot = _el('span', { class: 'cfg-modified-dot' }); | |
| const input = renderInput(field, section, isDirty ? _dirty[fp] : value); | |
| const labelChildren = [field.label]; | |
| if (section === 'app' && field.key === 'webui_enabled') { | |
| const btn = _el('button', { | |
| class: 'cfg-inline-action', | |
| type: 'button', | |
| title: 'ๆๅผ WebUI', | |
| 'aria-label': 'ๆๅผ WebUI', | |
| 'data-webui-link': 'true', | |
| onclick: () => window.open('/webui/login', '_blank', 'noopener,noreferrer'), | |
| }); | |
| btn._configField = field; | |
| btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 5h5v5"/><path d="M10 14 19 5"/><path d="M19 14v5h-14v-14h5"/></svg>'; | |
| btn.disabled = !Boolean(_getCurrentValue(section, field.key, field)); | |
| labelChildren.push(btn); | |
| } | |
| if (field.help) { | |
| labelChildren.push(_el('button', { | |
| class: 'cfg-help', | |
| type: 'button', | |
| 'aria-label': field.help, | |
| 'data-tip': field.help, | |
| onmouseenter: (event) => _showHelpTip(event.currentTarget), | |
| onmouseleave: _hideHelpTip, | |
| onfocus: (event) => _showHelpTip(event.currentTarget), | |
| onblur: _hideHelpTip, | |
| onclick: (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| _showHelpTip(event.currentTarget); | |
| }, | |
| }, '?')); | |
| } | |
| labelChildren.push(dot); | |
| const labelCol = _el('div', { class: 'cfg-label-col' }, | |
| _el('div', { class: 'cfg-label' }, labelChildren), | |
| field.desc ? _el('div', { class: 'cfg-desc' }, field.desc) : null, | |
| ); | |
| const inputCol = _el('div', { class: `cfg-input-col${field.type === 'bool' ? ' is-bool' : ''}` }, input); | |
| const row = _el('div', { | |
| class: `cfg-row${isDirty ? ' modified' : ''}`, | |
| 'data-section': section, | |
| 'data-key': field.key, | |
| }, labelCol, inputCol); | |
| _rowRefs.set(fp, row); | |
| _tabFieldIndex.set(fp, tabId); | |
| if (isDirty) _tabDirtyCounts.set(tabId, (_tabDirtyCounts.get(tabId) || 0) + 1); | |
| // Conditional visibility | |
| if (field.showIf) { | |
| row._showIfSpec = field.showIf; | |
| row.style.display = _evalShowIf(field.showIf) ? '' : 'none'; | |
| _conditionalRows.push(row); | |
| _showIfPaths(field.showIf).forEach((path) => _addDependency(_showIfDeps, path, row)); | |
| } | |
| return row; | |
| } | |
| function renderGroup(group, tabId) { | |
| const section = group.section; | |
| const rows = group.fields.map(f => renderRow(f, f.section || section, tabId)); | |
| const titleEl = _el('div', { class: 'cfg-group-title' }, group.title || ''); | |
| const wrap = _el('div', { class: 'cfg-group' }, titleEl, ...rows); | |
| return wrap; | |
| } | |
| function renderPanel(tab) { | |
| const panel = _el('div', { class: `cfg-panel${tab.id === _activeTab ? ' active' : ''}`, | |
| id: `panel-${tab.id}` }); | |
| tab.groups.forEach(g => panel.appendChild(renderGroup(g, tab.id))); | |
| return panel; | |
| } | |
| function renderAll() { | |
| const nav = document.getElementById('cfg-nav'); | |
| const panels = document.getElementById('cfg-panels'); | |
| _resetRenderCaches(); | |
| nav.innerHTML = ''; panels.innerHTML = ''; | |
| SCHEMA.forEach(tab => { | |
| // Tab button | |
| const btn = _el('button', { | |
| class: `cfg-tab${tab.id === _activeTab ? ' active' : ''}`, | |
| onclick: () => switchTab(tab.id), | |
| 'data-tab': tab.id, | |
| }, tab.label, _el('span', { class: 'dot' })); | |
| _tabButtons.set(tab.id, btn); | |
| nav.appendChild(btn); | |
| // Panel | |
| panels.appendChild(renderPanel(tab)); | |
| }); | |
| _tabButtons.forEach((_, tabId) => _syncTabDirtyState(tabId)); | |
| } | |
| function switchTab(id) { | |
| _activeTab = id; | |
| document.querySelectorAll('.cfg-tab').forEach(b => | |
| b.classList.toggle('active', b.dataset.tab === id)); | |
| document.querySelectorAll('.cfg-panel').forEach(p => | |
| p.classList.toggle('active', p.id === `panel-${id}`)); | |
| } | |
| // โโ Dirty state helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function _refreshRow(section, key) { | |
| const row = _rowRefs.get(_fullPath(section, key)); | |
| if (row) row.classList.toggle('modified', _fullPath(section, key) in _dirty); | |
| } | |
| function _refreshConditionalRows(changedPaths = null) { | |
| const rows = changedPaths ? _collectDependentTargets(changedPaths, _showIfDeps) : _conditionalRows; | |
| rows.forEach((row) => { | |
| row.style.display = _evalShowIf(row._showIfSpec) ? '' : 'none'; | |
| }); | |
| } | |
| function _refreshSelectStates(changedPaths = null, { autoNormalizeSelects = true } = {}) { | |
| const selects = changedPaths ? _collectDependentTargets(changedPaths, _disabledSelectDeps) : _selectRefs; | |
| selects.forEach((sel) => { | |
| let anyChanged = false; | |
| [...sel.options].forEach(o => { | |
| if (!o.dataset.disabledWhen) return; | |
| const isDisabled = _evalDisabledWhen(o.dataset.disabledWhen); | |
| o.disabled = isDisabled; | |
| o.title = isDisabled ? (o.dataset.disabledTip || '') : ''; | |
| if (autoNormalizeSelects && isDisabled && o.selected) anyChanged = true; | |
| }); | |
| if (anyChanged) { | |
| const firstEnabled = [...sel.options].find(o => !o.disabled); | |
| if (firstEnabled) { | |
| firstEnabled.selected = true; | |
| sel.dispatchEvent(new Event('change')); | |
| } | |
| } | |
| }); | |
| } | |
| function _refreshDirtyState({ autoNormalizeSelects = true, changedPaths = null } = {}) { | |
| const n = Object.keys(_dirty).length; | |
| document.getElementById('btn-save').disabled = n === 0; | |
| document.getElementById('btn-reset').disabled = n === 0; | |
| const bar = document.getElementById('save-bar'); | |
| bar.classList.toggle('visible', n > 0); | |
| document.getElementById('save-bar-msg').textContent = | |
| n === 0 | |
| ? tr('config.unsavedDefault', null, 'ๅญๅจๆชไฟๅญ็ๆดๆน') | |
| : n === 1 | |
| ? tr('config.unsavedSingle', { n }, 'ๅญๅจ 1 ้กนๆชไฟๅญ็ๆดๆน') | |
| : tr('config.unsavedMultiple', { n }, `ๅญๅจ ${n} ้กนๆชไฟๅญ็ๆดๆน`); | |
| const webuiLinkBtn = document.querySelector('[data-webui-link="true"]'); | |
| if (webuiLinkBtn) { | |
| const enabled = Boolean(_getCurrentValue('app', 'webui_enabled', webuiLinkBtn._configField)); | |
| webuiLinkBtn.disabled = !enabled; | |
| webuiLinkBtn.title = enabled ? 'ๆๅผ WebUI' : 'ๅฏ็จ WebUI ๅๅฏๆๅผ'; | |
| webuiLinkBtn.setAttribute('aria-label', webuiLinkBtn.title); | |
| } | |
| _refreshConditionalRows(changedPaths); | |
| _refreshSelectStates(changedPaths, { autoNormalizeSelects }); | |
| if (!changedPaths) _tabButtons.forEach((_, tabId) => _syncTabDirtyState(tabId)); | |
| } | |
| // โโ Load config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function load() { | |
| try { | |
| _cfg = await _api('GET', '/config'); | |
| _dirty = {}; | |
| _configLoaded = true; | |
| if (!SCHEMA.length) SCHEMA = buildSchema(); | |
| renderAll(); | |
| _refreshDirtyState({ autoNormalizeSelects: false }); | |
| } catch (e) { | |
| showToast(tr('config.loadFailed', { message: e.message }, `้ ็ฝฎๅ ่ฝฝๅคฑ่ดฅ: ${e.message}`), 'error'); | |
| } | |
| } | |
| // โโ Save โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function doSave() { | |
| if (!Object.keys(_dirty).length) return; | |
| // Build a properly nested patch from full dotted paths. | |
| // "proxy.egress.mode" โ {proxy:{egress:{mode:v}}} | |
| const patch = {}; | |
| Object.entries(_dirty).forEach(([fp, v]) => _setByPath(patch, fp, v)); | |
| try { | |
| showToast(tr('config.saving', null, 'ๆญฃๅจไฟๅญๆดๆนโฆ'), 'info'); | |
| await _api('POST', '/config', patch); | |
| // Merge into local _cfg so re-render shows correct values. | |
| Object.entries(_dirty).forEach(([fp, v]) => _setByPath(_cfg, fp, v)); | |
| _dirty = {}; | |
| // Re-render to clear modified state | |
| renderAll(); | |
| _refreshDirtyState({ autoNormalizeSelects: false }); | |
| showToast(tr('config.saveSuccess', null, 'ๆดๆนๅทฒไฟๅญ'), 'success'); | |
| } catch (e) { | |
| showToast(tr('config.saveFailed', { message: e.message }, `ไฟๅญๆดๆนๅคฑ่ดฅ: ${e.message}`), 'error'); | |
| } | |
| } | |
| // โโ Reset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function doReset() { | |
| if (!Object.keys(_dirty).length) return; | |
| _dirty = {}; | |
| renderAll(); | |
| _refreshDirtyState({ autoNormalizeSelects: false }); | |
| showToast(tr('config.resetSuccess', null, 'ๆชไฟๅญ็ๆดๆนๅทฒๆค้'), 'info'); | |
| } | |
| // โโ Boot โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| (async () => { | |
| try { | |
| SCHEMA = buildSchema(); | |
| waitI18n().then(() => { | |
| applyConfigI18n(); | |
| }).catch((e) => { | |
| console.error('i18n init failed', e); | |
| }); | |
| await renderAdminHeader?.(); | |
| await renderSiteFooter?.(); | |
| const key = await adminKey.get(); | |
| if (!key || !await verifyKey(ADMIN_API + '/verify', key).catch(() => false)) | |
| return location.href = '/admin/login'; | |
| await load(); | |
| } catch (e) { | |
| console.error('config page boot failed', e); | |
| showToast(tr('config.loadFailed', { message: e.message }, `้ ็ฝฎ้กตๅๅงๅๅคฑ่ดฅ: ${e.message}`), 'error'); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |