| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8"/>
- <title>v2 前端控制台 · 账号管理 + Chat 测试</title>
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
- <style>
- :root {
- --bg:#0a0e1a;
- --panel:#0f1420;
- --muted:#8b95a8;
- --text:#e8f0ff;
- --accent:#4f8fff;
- --danger:#ff4757;
- --ok:#2ed573;
- --warn:#ffa502;
- --border:#1a2332;
- --chip:#141b28;
- --code:#0d1218;
- --glow:rgba(79,143,255,.15);
- }
- * { box-sizing:border-box; }
- html, body { height:100%; margin:0; }
- body {
- padding:0 0 80px;
- background:radial-gradient(ellipse at top, #0f1624 0%, #0a0e1a 100%);
- color:var(--text);
- font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Noto Sans,Arial,sans-serif;
- line-height:1.6;
- }
- h1,h2,h3 { font-weight:700; letter-spacing:-.02em; margin:0; }
- h1 { font-size:28px; margin:24px 0 12px; background:linear-gradient(135deg,#4f8fff,#7b9fff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
- h2 { font-size:18px; margin:20px 0 16px; color:#c5d4ff; }
- h3 { font-size:15px; margin:16px 0 10px; color:#a8b8d8; }
- .container { max-width:1280px; margin:0 auto; padding:20px; }
- .grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
- @media(max-width:1024px){ .grid { grid-template-columns:1fr; } }
- .panel {
- background:linear-gradient(145deg,rgba(15,20,32,.8),rgba(10,14,26,.9));
- border:1px solid var(--border);
- border-radius:16px;
- padding:24px;
- box-shadow:0 20px 60px rgba(0,0,0,.4),0 0 0 1px rgba(79,143,255,.08),inset 0 1px 0 rgba(255,255,255,.03);
- backdrop-filter:blur(12px);
- transition:transform .2s,box-shadow .2s;
- }
- .panel:hover { transform:translateY(-2px); box-shadow:0 24px 70px rgba(0,0,0,.5),0 0 0 1px rgba(79,143,255,.12),inset 0 1px 0 rgba(255,255,255,.04); }
- .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
- label { color:var(--muted); font-size:13px; font-weight:500; letter-spacing:.01em; }
- .field { display:flex; flex-direction:column; gap:8px; flex:1; min-width:200px; }
- input,textarea,select {
- background:rgba(12,16,28,.6);
- color:var(--text);
- border:1px solid var(--border);
- border-radius:12px;
- padding:12px 14px;
- outline:none;
- transition:all .2s;
- font-size:14px;
- box-shadow:inset 0 1px 2px rgba(0,0,0,.2);
- }
- input:focus,textarea:focus,select:focus {
- border-color:var(--accent);
- box-shadow:0 0 0 3px var(--glow),inset 0 1px 2px rgba(0,0,0,.2);
- background:rgba(12,16,28,.8);
- }
- textarea { min-height:140px; resize:vertical; font-family:ui-monospace,monospace; }
- button {
- background:linear-gradient(135deg,#2563eb,#1e40af);
- color:#fff;
- border:none;
- border-radius:12px;
- padding:12px 20px;
- font-weight:600;
- font-size:14px;
- cursor:pointer;
- transition:all .2s;
- box-shadow:0 4px 16px rgba(37,99,235,.3),inset 0 1px 0 rgba(255,255,255,.1);
- position:relative;
- overflow:hidden;
- }
- button:before {
- content:'';
- position:absolute;
- top:0;left:0;right:0;bottom:0;
- background:linear-gradient(135deg,rgba(255,255,255,.1),transparent);
- opacity:0;
- transition:opacity .2s;
- }
- button:hover { transform:translateY(-1px); box-shadow:0 6px 20px rgba(37,99,235,.4),inset 0 1px 0 rgba(255,255,255,.15); }
- button:hover:before { opacity:1; }
- button:active { transform:translateY(0); }
- button:disabled { opacity:.5; cursor:not-allowed; transform:none; }
- .btn-secondary { background:linear-gradient(135deg,#1e293b,#0f172a); box-shadow:0 4px 16px rgba(15,23,42,.3),inset 0 1px 0 rgba(255,255,255,.05); }
- .btn-secondary:hover { box-shadow:0 6px 20px rgba(15,23,42,.4),inset 0 1px 0 rgba(255,255,255,.08); }
- .btn-danger { background:linear-gradient(135deg,#dc2626,#991b1b); box-shadow:0 4px 16px rgba(220,38,38,.3),inset 0 1px 0 rgba(255,255,255,.1); }
- .btn-danger:hover { box-shadow:0 6px 20px rgba(220,38,38,.4),inset 0 1px 0 rgba(255,255,255,.15); }
- .btn-warn { background:linear-gradient(135deg,#f59e0b,#d97706); box-shadow:0 4px 16px rgba(245,158,11,.3),inset 0 1px 0 rgba(255,255,255,.1); }
- .btn-warn:hover { box-shadow:0 6px 20px rgba(245,158,11,.4),inset 0 1px 0 rgba(255,255,255,.15); }
- .kvs { display:grid; grid-template-columns:160px 1fr; gap:10px 16px; font-size:13px; }
- .muted { color:var(--muted); }
- .chip {
- display:inline-flex;
- align-items:center;
- gap:6px;
- padding:6px 12px;
- background:rgba(20,27,40,.8);
- border:1px solid var(--border);
- border-radius:20px;
- color:#a8b8ff;
- font-size:12px;
- font-weight:500;
- box-shadow:0 2px 8px rgba(0,0,0,.2);
- }
- .list { display:flex; flex-direction:column; gap:12px; max-height:400px; overflow:auto; padding:2px; }
- .list::-webkit-scrollbar { width:8px; }
- .list::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
- .list::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
- .list::-webkit-scrollbar-thumb:hover { background:rgba(79,143,255,.5); }
- .card {
- border:1px solid var(--border);
- border-radius:14px;
- padding:16px;
- background:linear-gradient(145deg,rgba(12,19,34,.6),rgba(10,14,26,.8));
- display:flex;
- flex-direction:column;
- gap:12px;
- box-shadow:0 4px 16px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.02);
- transition:all .2s;
- }
- .card:hover { border-color:rgba(79,143,255,.3); box-shadow:0 6px 20px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.03); }
- .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; }
- .code {
- background:var(--code);
- border:1px solid var(--border);
- border-radius:12px;
- padding:14px;
- color:#d8e8ff;
- max-height:300px;
- overflow:auto;
- white-space:pre-wrap;
- font-size:13px;
- line-height:1.6;
- box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
- }
- .code::-webkit-scrollbar { width:8px; height:8px; }
- .code::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
- .code::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
- .right { margin-left:auto; }
- .sep { height:1px; background:linear-gradient(90deg,transparent,rgba(79,143,255,.2),transparent); margin:16px 0; }
- .footer {
- position:fixed;
- left:0;right:0;bottom:0;
- background:rgba(10,14,26,.85);
- backdrop-filter:blur(16px);
- border-top:1px solid var(--border);
- padding:14px 20px;
- box-shadow:0 -4px 20px rgba(0,0,0,.3);
- }
- .status-ok { color:var(--ok); font-weight:600; }
- .status-fail { color:var(--danger); font-weight:600; }
- .switch { position:relative; display:inline-block; width:50px; height:26px; }
- .switch input { opacity:0; width:0; height:0; }
- .slider {
- position:absolute;
- cursor:pointer;
- top:0;left:0;right:0;bottom:0;
- background:linear-gradient(135deg,#374151,#1f2937);
- transition:.3s;
- border-radius:26px;
- border:1px solid var(--border);
- box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
- }
- .slider:before {
- position:absolute;
- content:"";
- height:20px;
- width:20px;
- left:3px;
- bottom:2px;
- background:linear-gradient(135deg,#f3f4f6,#e5e7eb);
- transition:.3s;
- border-radius:50%;
- box-shadow:0 2px 6px rgba(0,0,0,.3);
- }
- input:checked+.slider { background:linear-gradient(135deg,#3b82f6,#2563eb); box-shadow:0 0 12px rgba(59,130,246,.4),inset 0 2px 4px rgba(0,0,0,.2); }
- input:checked+.slider:before { transform:translateX(24px); }
- @keyframes fadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
- .panel { animation:fadeIn .4s ease-out; }
- </style>
- </head>
- <body>
- <div class="container">
- <h1>v2 前端控制台</h1>
- <div class="panel">
- <div class="row">
- <div class="field" style="max-width:420px">
- <label>API Base</label>
- <input id="base" value="/" />
- </div>
- <div class="field" style="max-width:520px">
- <label>Authorization(OpenAI风格白名单;仅授权用途;OPENAI_KEYS 为空时可留空)</label>
- <input id="auth" placeholder="自定义Key(可留空:开发模式)" />
- </div>
- <div class="field" style="max-width:300px">
- <label>健康检查</label>
- <div class="row">
- <button class="btn-secondary" onclick="ping()">Ping</button>
- <div id="health" class="chip">未检测</div>
- </div>
- </div>
- </div>
- <div class="sep"></div>
- <div class="row">
- <div class="chip mono">OPENAI_KEYS="key1,key2"(白名单,仅授权,与账号无关)</div>
- <div class="chip mono">当 OPENAI_KEYS 为空或未配置:开发模式,不校验 Authorization</div>
- <div class="chip mono">账号选择:从所有“启用”的账号中随机选择</div>
- </div>
- </div>
- <div class="grid" style="margin-top:12px">
- <div class="panel">
- <h2>账号管理</h2>
- <div class="row">
- <button class="btn-secondary" onclick="loadAccounts()">刷新列表</button>
- </div>
- <div class="list" id="accounts"></div>
- <div class="sep"></div>
- <h3>创建账号</h3>
- <div class="row">
- <div class="field"><label>label</label><input id="new_label" /></div>
- <div class="field"><label>clientId</label><input id="new_clientId" /></div>
- <div class="field"><label>clientSecret</label><input id="new_clientSecret" /></div>
- </div>
- <div class="row">
- <div class="field"><label>refreshToken</label><input id="new_refreshToken" /></div>
- <div class="field"><label>accessToken</label><input id="new_accessToken" /></div>
- </div>
- <div class="row">
- <div class="field">
- <label>other(JSON,可选)</label>
- <textarea id="new_other" placeholder='{"note":"备注"}'></textarea>
- </div>
- <div class="field" style="max-width:220px">
- <label>启用(仅启用账号会被用于请求)</label>
- <div>
- <label class="switch">
- <input id="new_enabled" type="checkbox" checked />
- <span class="slider"></span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <button onclick="createAccount()">创建</button>
- </div>
- <div class="sep"></div>
- <h3>URL 登录(5分钟超时)</h3>
- <div class="row">
- <div class="field"><label>label(可选)</label><input id="auth_label" /></div>
- <div class="field" style="max-width:220px">
- <label>启用(登录成功后新账号是否启用)</label>
- <div>
- <label class="switch">
- <input id="auth_enabled" type="checkbox" checked />
- <span class="slider"></span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <button onclick="startAuth()">开始登录</button>
- <button class="btn-secondary" onclick="claimAuth()">等待授权并创建账号</button>
- </div>
- <div class="field">
- <label>登录信息</label>
- <pre class="code mono" id="auth_info">尚未开始</pre>
- </div>
- </div>
- <div class="panel">
- <h2>Chat 测试(OpenAI 兼容 /v1/chat/completions)</h2>
- <div class="row">
- <div class="field" style="max-width:300px">
- <label>model</label>
- <input id="model" value="claude-sonnet-4" />
- </div>
- <div class="field" style="max-width:180px">
- <label>是否流式</label>
- <select id="stream">
- <option value="false">false(默认)</option>
- <option value="true">true(SSE)</option>
- </select>
- </div>
- <button class="right" onclick="send()">发送请求</button>
- </div>
- <div class="field">
- <label>messages(JSON)</label>
- <textarea id="messages">[
- {"role":"system","content":"你是一个乐于助人的助手"},
- {"role":"user","content":"你好,请讲一个简短的故事"}
- ]</textarea>
- </div>
- <div class="field">
- <label>响应</label>
- <pre class="code mono" id="out"></pre>
- </div>
- </div>
- </div>
- </div>
- <div class="footer">
- <div class="container row">
- <div class="muted">提示:在 .env 配置 OPENAI_KEYS 白名单;账号选择与 key 无关,将在“启用”的账号中随机选择。</div>
- <div class="right muted">v2 OpenAI-Compatible</div>
- </div>
- </div>
- <script>
- function baseUrl(){ return document.getElementById('base').value.trim(); }
- function authHeader(){
- const v = document.getElementById('auth').value.trim();
- return v ? ('Bearer ' + v) : '';
- }
- function setHealth(text, ok=true) {
- const el = document.getElementById('health');
- el.textContent = text;
- el.style.color = ok ? 'var(--ok)' : 'var(--danger)';
- }
- function api(path){
- const b = baseUrl();
- const baseClean = b.replace(/\/+$/, '');
- const p = typeof path === 'string' ? path : '';
- const pathClean = ('/' + p.replace(/^\/+/, '')).replace(/\/{2,}/g, '/');
- return (baseClean ? baseClean : '') + pathClean;
- }
- async function ping(){
- try{
- const r = await fetch(api('/healthz'));
- const j = await r.json();
- if (j && j.status === 'ok') setHealth('Healthy', true);
- else setHealth('Unhealthy', false);
- } catch(e){
- setHealth('Error', false);
- }
- }
- function renderAccounts(list){
- const root = document.getElementById('accounts');
- root.innerHTML = '';
- if (!Array.isArray(list) || list.length === 0) {
- const empty = document.createElement('div');
- empty.className = 'muted';
- empty.textContent = '暂无账号';
- root.appendChild(empty);
- return;
- }
- for (const acc of list) {
- const card = document.createElement('div');
- card.className = 'card';
- const header = document.createElement('div');
- header.className = 'row';
- const name = document.createElement('div');
- name.innerHTML = '<strong>' + (acc.label || '(无标签)') + '</strong>';
- const id = document.createElement('div');
- id.className = 'chip mono';
- id.textContent = acc.id;
- const spacer = document.createElement('div');
- spacer.className = 'right';
- // Enabled toggle
- const toggleWrap = document.createElement('div');
- const toggleLabel = document.createElement('label');
- toggleLabel.style.marginRight = '6px';
- toggleLabel.className = 'muted';
- toggleLabel.textContent = '启用';
- const toggle = document.createElement('label');
- toggle.className = 'switch';
- const chk = document.createElement('input');
- chk.type = 'checkbox';
- chk.checked = !!acc.enabled;
- chk.onchange = async () => {
- try {
- await updateAccount(acc.id, { enabled: chk.checked });
- } catch(e) {
- // revert if failed
- chk.checked = !chk.checked;
- }
- };
- const slider = document.createElement('span');
- slider.className = 'slider';
- toggle.appendChild(chk); toggle.appendChild(slider);
- toggleWrap.appendChild(toggleLabel); toggleWrap.appendChild(toggle);
- const refreshBtn = document.createElement('button');
- refreshBtn.className = 'btn-warn';
- refreshBtn.textContent = '刷新Token';
- refreshBtn.onclick = () => refreshAccount(acc.id);
- const delBtn = document.createElement('button');
- delBtn.className = 'btn-danger';
- delBtn.textContent = '删除';
- delBtn.onclick = () => deleteAccount(acc.id);
- header.appendChild(name);
- header.appendChild(id);
- header.appendChild(spacer);
- header.appendChild(toggleWrap);
- header.appendChild(refreshBtn);
- header.appendChild(delBtn);
- card.appendChild(header);
- const meta = document.createElement('div');
- meta.className = 'kvs mono';
- function row(k, v) {
- const kEl = document.createElement('div'); kEl.className = 'muted'; kEl.textContent = k;
- const vEl = document.createElement('div'); vEl.textContent = v ?? '';
- meta.appendChild(kEl); meta.appendChild(vEl);
- }
- row('enabled', String(!!acc.enabled));
- row('last_refresh_status', acc.last_refresh_status);
- row('last_refresh_time', acc.last_refresh_time);
- row('clientId', acc.clientId);
- row('hasRefreshToken', acc.refreshToken ? 'yes' : 'no');
- row('hasAccessToken', acc.accessToken ? 'yes' : 'no');
- row('created_at', acc.created_at);
- row('updated_at', acc.updated_at);
- if (acc.other) {
- row('other', JSON.stringify(acc.other));
- }
- card.appendChild(meta);
- // quick edit form (label, accessToken)
- const editRow = document.createElement('div');
- editRow.className = 'row';
- editRow.style.marginTop = '8px';
- const labelField = document.createElement('input');
- labelField.placeholder = 'label';
- labelField.value = acc.label || '';
- const accessField = document.createElement('input');
- accessField.placeholder = 'accessToken(可选)';
- accessField.value = acc.accessToken || '';
- const saveBtn = document.createElement('button');
- saveBtn.className = 'btn-secondary';
- saveBtn.textContent = '保存';
- saveBtn.onclick = async () => {
- await updateAccount(acc.id, { label: labelField.value, accessToken: accessField.value });
- };
- editRow.appendChild(labelField);
- editRow.appendChild(accessField);
- editRow.appendChild(saveBtn);
- card.appendChild(editRow);
- root.appendChild(card);
- }
- }
- async function loadAccounts(){
- try{
- const r = await fetch(api('/v2/accounts'));
- const j = await r.json();
- renderAccounts(j);
- } catch(e){
- alert('加载账户失败:' + e);
- }
- }
- async function createAccount(){
- const body = {
- label: document.getElementById('new_label').value.trim() || null,
- clientId: document.getElementById('new_clientId').value.trim(),
- clientSecret: document.getElementById('new_clientSecret').value.trim(),
- refreshToken: document.getElementById('new_refreshToken').value.trim() || null,
- accessToken: document.getElementById('new_accessToken').value.trim() || null,
- enabled: document.getElementById('new_enabled').checked,
- other: (()=>{
- const t = document.getElementById('new_other').value.trim();
- if (!t) return null;
- try { return JSON.parse(t); } catch { alert('other 不是合法 JSON'); throw new Error('bad other'); }
- })()
- };
- try{
- const r = await fetch(api('/v2/accounts'), {
- method:'POST',
- headers:{ 'content-type':'application/json' },
- body: JSON.stringify(body)
- });
- if (!r.ok) {
- const t = await r.text();
- throw new Error(t);
- }
- await loadAccounts();
- } catch(e){
- alert('创建失败:' + e);
- }
- }
- async function deleteAccount(id){
- if (!confirm('确认删除该账号?')) return;
- try{
- const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id)), { method:'DELETE' });
- if (!r.ok) { throw new Error(await r.text()); }
- await loadAccounts();
- } catch(e){
- alert('删除失败:' + e);
- }
- }
- async function updateAccount(id, patch){
- try{
- const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id)), {
- method:'PATCH',
- headers:{ 'content-type':'application/json' },
- body: JSON.stringify(patch)
- });
- if (!r.ok) { throw new Error(await r.text()); }
- await loadAccounts();
- } catch(e){
- alert('更新失败:' + e);
- }
- }
- async function refreshAccount(id){
- try{
- const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id) + '/refresh'), { method:'POST' });
- if (!r.ok) { throw new Error(await r.text()); }
- await loadAccounts();
- } catch(e){
- alert('刷新失败:' + e);
- }
- }
- // URL Login (Device Authorization)
- let currentAuth = null;
- async function startAuth(){
- const body = {
- label: (document.getElementById('auth_label').value || '').trim() || null,
- enabled: document.getElementById('auth_enabled').checked
- };
- try {
- const r = await fetch(api('/v2/auth/start'), {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify(body)
- });
- if (!r.ok) throw new Error(await r.text());
- const j = await r.json();
- currentAuth = j;
- const info = [
- '验证链接: ' + j.verificationUriComplete,
- '用户代码: ' + (j.userCode || ''),
- 'authId: ' + j.authId,
- 'expiresIn: ' + j.expiresIn + 's',
- 'interval: ' + j.interval + 's'
- ].join('\\n');
- const el = document.getElementById('auth_info');
- el.textContent = info + '\\n\\n请在新窗口中打开上述链接完成登录。';
- try { window.open(j.verificationUriComplete, '_blank'); } catch {}
- } catch(e){
- document.getElementById('auth_info').textContent = '启动失败:' + e;
- }
- }
- async function claimAuth(){
- if (!currentAuth || !currentAuth.authId) {
- document.getElementById('auth_info').textContent = '请先点击“开始登录”。';
- return;
- }
- document.getElementById('auth_info').textContent += '\\n\\n正在等待授权并创建账号(最多5分钟)...';
- try{
- const r = await fetch(api('/v2/auth/claim/' + encodeURIComponent(currentAuth.authId)), { method: 'POST' });
- const text = await r.text();
- let j;
- try { j = JSON.parse(text); } catch { j = { raw: text }; }
- document.getElementById('auth_info').textContent = '完成:\\n' + JSON.stringify(j, null, 2);
- await loadAccounts();
- } catch(e){
- document.getElementById('auth_info').textContent += '\\n失败:' + e;
- }
- }
- async function send() {
- const base = baseUrl();
- const auth = authHeader();
- const model = document.getElementById('model').value.trim();
- const stream = document.getElementById('stream').value === 'true';
- const out = document.getElementById('out');
- out.textContent = '';
- let messages;
- try { messages = JSON.parse(document.getElementById('messages').value); }
- catch(e){ out.textContent = 'messages 不是合法 JSON'; return; }
- const body = { model, messages, stream };
- const headers = { 'content-type': 'application/json' };
- if (auth) headers['authorization'] = auth;
- if (!stream) {
- const r = await fetch(api('/v1/chat/completions'), {
- method:'POST',
- headers,
- body: JSON.stringify(body)
- });
- const text = await r.text();
- try { out.textContent = JSON.stringify(JSON.parse(text), null, 2); }
- catch { out.textContent = text; }
- } else {
- const r = await fetch(api('/v1/chat/completions'), {
- method:'POST',
- headers,
- body: JSON.stringify(body)
- });
- const reader = r.body.getReader();
- const decoder = new TextDecoder();
- while (true) {
- const {value, done} = await reader.read();
- if (done) break;
- out.textContent += decoder.decode(value, {stream:true});
- }
- }
- }
- window.addEventListener('DOMContentLoaded', () => {
- loadAccounts();
- ping();
- });
- </script>
- </body>
- </html>
|