<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Key Tester (浏览器直连版)</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background: #0b1020;
color: #e5e7eb;
}
.wrap { max-width: 1080px; margin: 36px auto; padding: 0 16px; }
.card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 18px; }
h1 { margin: 0 0 6px; font-size: 22px; }
.sub { color: #94a3b8; font-size: 13px; margin-bottom: 16px; }
.grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 600px) { .row2 { grid-template-columns: 1fr; } }
label { font-size: 13px; color: #cbd5e1; margin-bottom: 4px; display: block; }
input, textarea {
width: 100%; box-sizing: border-box;
border: 1px solid #374151; background: #0f172a; color: #e2e8f0;
border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit;
}
textarea { min-height: 110px; resize: vertical; }
textarea#apiKeys { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; min-height: 140px; }
button {
margin-top: 6px; border: 0; background: #2563eb; color: #fff;
padding: 10px 14px; border-radius: 8px; font-size: 14px; cursor: pointer;
}
button:disabled { opacity: .6; cursor: not-allowed; }
button.secondary { background: #374151; }
.summary {
margin-top: 14px; padding: 10px 12px; border-radius: 8px; font-size: 14px;
background: #0f172a; border: 1px solid #1f2937; display: none;
}
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; margin-right: 6px; }
.pill-ok { background: #052e16; color: #bbf7d0; border: 1px solid #166534; }
.pill-busy { background: #422006; color: #fde68a; border: 1px solid #b45309; }
.pill-err { background: #3f0c17; color: #fecdd3; border: 1px solid #9f1239; }
.pill-pending { background: #1e293b; color: #94a3b8; border: 1px solid #334155; }
table {
width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px;
display: none;
}
th, td {
text-align: left; padding: 8px 10px; border-bottom: 1px solid #1f2937; vertical-align: top;
}
th { background: #0f172a; color: #94a3b8; font-weight: 600; font-size: 12px; }
td.key { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 12px; max-width: 180px; word-break: break-all; }
td.reply { white-space: pre-wrap; word-break: break-word; max-width: 420px; color: #e2e8f0; }
td.reply.empty { color: #64748b; font-style: italic; }
td.status { white-space: nowrap; }
td.duration { color: #94a3b8; white-space: nowrap; }
.hint { color: #94a3b8; font-size: 12px; margin-top: 8px; }
.toolbar { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.combo { position: relative; }
.combo-panel {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: #0f172a; border: 1px solid #334155; border-radius: 8px;
max-height: 260px; overflow-y: auto; z-index: 50; display: none;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.combo-panel.open { display: block; }
.combo-item {
padding: 8px 12px; font-size: 13px; cursor: pointer; color: #e2e8f0;
font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
}
.combo-item:hover, .combo-item.active { background: #1e293b; }
.combo-empty { padding: 10px 12px; font-size: 12px; color: #64748b; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Claude Key 可用性测试</h1>
<div class="sub">纯前端版:浏览器直接调用上游 API,无需后端,Key 不经任何中间服务</div>
<div class="grid">
<div>
<label for="baseUrl">Base URL</label>
<input id="baseUrl" placeholder="例如: https://anyrouter.top" value="https://anyrouter.top" />
</div>
<div>
<label for="apiKeys">API Keys(每行一个,支持批量;# 开头视为注释)</label>
<textarea id="apiKeys" placeholder="sk-ant-... sk-xxxxxxxx" autocomplete="off"></textarea>
</div>
<div class="row2">
<div>
<label for="model">Model(聚焦自动拉取可用列表)</label>
<div class="combo">
<input id="model" placeholder="默认 claude-opus-4-7" value="claude-opus-4-7" autocomplete="off" />
<div id="modelPanel" class="combo-panel"></div>
</div>
<div id="modelHint" class="hint" style="margin-top:4px"></div>
</div>
<div>
<label for="concurrency">并发数</label>
<input id="concurrency" type="number" min="1" max="20" value="5" />
</div>
</div>
<div>
<label for="enable1m">
<input id="enable1m" type="checkbox" checked style="width:auto; margin-right:8px; vertical-align:middle;" />
启用 1m 上下文 + Claude Code 指纹
</label>
</div>
<div>
<label for="prompt">测试文案</label>
<textarea id="prompt" placeholder="输入你想测试的文本">你是什么模型</textarea>
</div>
</div>
<div class="toolbar">
<button id="runBtn">开始测试</button>
<button id="copyOkBtn" class="secondary" style="display:none">复制可用 Key</button>
<button id="exportBtn" class="secondary" style="display:none">导出结果 JSON</button>
</div>
<div id="summary" class="summary"></div>
<table id="results">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Key</th>
<th style="width:120px">状态</th>
<th>回复</th>
<th style="width:70px">耗时</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
</table>
<div class="hint">
浏览器直连 <code>POST {baseUrl}/v1/messages?beta=true</code>,依赖上游开启 CORS(anyrouter 已开)。
Key 仅在你浏览器内存里,刷新后丢失,不会上传任何第三方。
</div>
</div>
</div>
<script>
// ============================================================
// 常量
// ============================================================
const ANTHROPIC_VERSION = '2023-06-01';
const CONTEXT_1M_BETA = 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,effort-2025-11-24';
const DEFAULT_MAX_TOKENS = 256;
// 模拟 Claude Code 的设备/会话标识:页面加载时生成一次,同一窗口内全部请求共用
// crypto.randomUUID() 仅在 secure context (HTTPS / localhost / file://) 可用,
// 普通 http:// 远程访问时回落到 getRandomValues + 手动拼 v4。
function genUUIDv4() {
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
arr[6] = (arr[6] & 0x0f) | 0x40;
arr[8] = (arr[8] & 0x3f) | 0x80;
const h = Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
const DEVICE_ID = (() => {
const buf = new Uint8Array(32);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
})();
const SESSION_ID = typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : genUUIDv4();
// ============================================================
// 工具函数(端口自 server.js)
// ============================================================
function normalizeBaseUrl(input) {
const base = String(input || '').trim();
if (!base) throw new Error('baseUrl 不能为空');
if (!/^https?:\/\//i.test(base)) throw new Error('baseUrl 必须以 http:// 或 https:// 开头');
return base.replace(/\/+$/, '');
}
function apiHeaders(apiKey, anthropicBeta = '') {
// 注意:浏览器禁止设置 User-Agent,由浏览器自带(new-api 不校验 UA)
const headers = {
'content-type': 'application/json',
'x-api-key': apiKey,
'authorization': `Bearer ${apiKey}`,
'anthropic-version': ANTHROPIC_VERSION,
'anthropic-dangerous-direct-browser-access': 'true',
'x-app': 'cli',
};
if (anthropicBeta) headers['anthropic-beta'] = anthropicBeta;
return headers;
}
function extractReplyText(body) {
if (!body || !Array.isArray(body.content)) return '';
return body.content
.filter((it) => it && it.type === 'text' && typeof it.text === 'string')
.map((it) => it.text)
.join('\n')
.trim();
}
function parseSSEText(sse) {
const out = [];
for (const line of sse.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const evt = JSON.parse(payload);
if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
out.push(evt.delta.text || '');
}
} catch {}
}
return out.join('').trim();
}
function extractErrMsg(body) {
if (!body) return '';
if (typeof body === 'string') return body;
if (body.error && typeof body.error === 'object') {
return body.error.message || JSON.stringify(body.error);
}
if (body.message) return String(body.message);
return JSON.stringify(body);
}
function maybeModelIssue(status, body) {
const msg = extractErrMsg(body).toLowerCase();
if (!msg) return status >= 500;
if ([400, 404, 422].includes(status) && /(model|unknown model|invalid model|not found|not available|unsupported)/i.test(msg)) return true;
if (status >= 500 && /(panic|nil pointer|invalid memory|runtime error)/i.test(msg)) return true;
return false;
}
function dedupeAttempts(attempts) {
const seen = new Set();
const out = [];
for (const a of attempts) {
const k = `${a.model}||${a.anthropicBeta || ''}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(a);
}
return out;
}
function normalizeModelDots(model) {
if (!model) return model;
return model.replace(/(claude-[a-z]+)-(\d+)\.(\d+)/i, '$1-$2-$3');
}
function buildAttempts(model, enable1m) {
const attempts = [];
const variants = [model];
const dashed = normalizeModelDots(model);
if (dashed && dashed !== model) variants.push(dashed);
for (const m of variants) {
if (enable1m) attempts.push({ model: m, anthropicBeta: CONTEXT_1M_BETA, note: 'beta header + ?beta=true' });
attempts.push({ model: m, anthropicBeta: '', note: 'no beta' });
}
return dedupeAttempts(attempts);
}
// ============================================================
// HTTP 调用(直连上游)
// ============================================================
async function readResponse(response) {
const text = await response.text();
try { return { rawText: text, json: JSON.parse(text) }; }
catch { return { rawText: text, json: null }; }
}
async function callMessages({ baseUrl, apiKey, model, prompt, anthropicBeta = '', maxTokens = DEFAULT_MAX_TOKENS }) {
const useBetaQuery = !!anthropicBeta;
const endpoint = `${baseUrl}/v1/messages${useBetaQuery ? '?beta=true' : ''}`;
const body = {
model,
max_tokens: maxTokens,
messages: [{ role: 'user', content: prompt }],
};
if (anthropicBeta) {
body.stream = true;
body.thinking = { type: 'adaptive' };
body.output_config = { effort: 'xhigh' };
body.metadata = {
user_id: JSON.stringify({
device_id: DEVICE_ID,
account_uuid: '',
session_id: SESSION_ID,
}),
};
body.system = [
{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude.", cache_control: { type: 'ephemeral' } },
];
body.messages = [{ role: 'user', content: [{ type: 'text', text: prompt, cache_control: { type: 'ephemeral' } }] }];
}
const response = await fetch(endpoint, {
method: 'POST',
headers: apiHeaders(apiKey, anthropicBeta),
body: JSON.stringify(body),
});
if (anthropicBeta && response.ok && (response.headers.get('content-type') || '').includes('event-stream')) {
const text = await response.text();
return {
ok: true, status: response.status, endpoint,
body: { stream: true, events: text.length },
rawText: '', replyText: parseSSEText(text),
};
}
const parsed = await readResponse(response);
return {
ok: response.ok, status: response.status, endpoint,
body: parsed.json, rawText: parsed.rawText,
replyText: extractReplyText(parsed.json),
};
}
async function listModels(baseUrl, apiKey) {
const endpoint = `${baseUrl}/v1/models`;
const response = await fetch(endpoint, { method: 'GET', headers: apiHeaders(apiKey) });
const parsed = await readResponse(response);
const ids = Array.isArray(parsed.json?.data)
? parsed.json.data.map((m) => m?.id).filter(Boolean)
: [];
return {
ok: response.ok, status: response.status, endpoint,
body: parsed.json, rawText: parsed.rawText, modelIds: ids,
};
}
// ============================================================
// 单 Key 完整测试流程(含重试 + 模型回退)
// ============================================================
async function testKey({ baseUrl, apiKey, model, prompt, enable1m }) {
const start = Date.now();
let usedModel = model;
let usedAnthropicBeta = '';
let result = null;
let fallbackTried = false;
let modelCandidates = [];
const attemptsLog = [];
try {
const attempts = buildAttempts(model, enable1m);
for (const at of attempts) {
const cur = await callMessages({ baseUrl, apiKey, model: at.model, prompt, anthropicBeta: at.anthropicBeta });
attemptsLog.push({ model: at.model, anthropicBeta: at.anthropicBeta || undefined, note: at.note, status: cur.status, ok: cur.ok });
result = cur;
usedModel = at.model;
usedAnthropicBeta = at.anthropicBeta || '';
if (cur.ok) break;
if ([502, 503, 504].includes(cur.status)) break;
}
if (result && !result.ok && maybeModelIssue(result.status, result.body || result.rawText)) {
const ml = await listModels(baseUrl, apiKey);
modelCandidates = ml.modelIds;
const fb = modelCandidates[0];
if (fb && fb !== usedModel) {
fallbackTried = true;
const retry = await callMessages({ baseUrl, apiKey, model: fb, prompt, anthropicBeta: usedAnthropicBeta });
attemptsLog.push({ model: fb, anthropicBeta: usedAnthropicBeta || undefined, note: 'fallback(first model from /v1/models)', status: retry.status, ok: retry.ok });
usedModel = fb;
result = retry;
}
}
} catch (err) {
return {
success: false, canUseKey: false,
durationMs: Date.now() - start,
message: `请求异常:${err?.message || err}`,
request: { baseUrl, model: usedModel, enable1m, attempts: attemptsLog },
response: null,
};
}
if (!result) {
return {
success: false, canUseKey: false,
durationMs: Date.now() - start,
message: '未执行任何请求',
};
}
const upstreamBusy = !result.ok && [502, 503, 504].includes(result.status);
const canUseKey = result.ok || upstreamBusy;
const durationMs = Date.now() - start;
return {
success: result.ok,
canUseKey,
upstreamBusy: upstreamBusy || undefined,
durationMs,
request: {
baseUrl, endpoint: '/v1/messages', anthropicVersion: ANTHROPIC_VERSION,
model: usedModel, enable1m,
anthropicBeta: usedAnthropicBeta || undefined,
fallbackTried, modelCandidates, attempts: attemptsLog,
},
response: {
status: result.status,
replyText: result.replyText,
body: result.body,
rawText: result.body ? undefined : result.rawText,
},
message: result.ok
? 'Key 可用,消息请求成功'
: upstreamBusy
? `Key 应该可用:上游临时不可用 (HTTP ${result.status}),认证已通过`
: `请求失败:${extractErrMsg(result.body || result.rawText) || `HTTP ${result.status}`}`,
};
}
// ============================================================
// UI 逻辑
// ============================================================
const els = {
baseUrl: document.getElementById('baseUrl'),
apiKeys: document.getElementById('apiKeys'),
model: document.getElementById('model'),
modelPanel: document.getElementById('modelPanel'),
modelHint: document.getElementById('modelHint'),
concurrency: document.getElementById('concurrency'),
enable1m: document.getElementById('enable1m'),
prompt: document.getElementById('prompt'),
runBtn: document.getElementById('runBtn'),
copyOkBtn: document.getElementById('copyOkBtn'),
exportBtn: document.getElementById('exportBtn'),
summary: document.getElementById('summary'),
table: document.getElementById('results'),
tbody: document.getElementById('resultsBody'),
};
let lastResults = [];
function maskKey(k) {
if (!k) return '';
if (k.length <= 14) return k;
return `${k.slice(0, 8)}…${k.slice(-6)}`;
}
function parseKeys(text) {
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith('#'));
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function pillFor(result) {
if (result.pending) return '<span class="pill pill-pending">⏳ 测试中</span>';
if (result.error) return '<span class="pill pill-err">✗ 网络错误</span>';
const data = result.data || {};
if (data.success) return '<span class="pill pill-ok">✓ 可用</span>';
if (data.upstreamBusy) return `<span class="pill pill-busy">⚠ 上游忙 (${data.response?.status || '5xx'})</span>`;
return `<span class="pill pill-err">✗ 不可用 (${data.response?.status || '?'})</span>`;
}
function renderRow(idx, result) {
const tr = document.createElement('tr');
tr.dataset.idx = idx;
const data = result.data || {};
const reply = data.response?.replyText || '';
const duration = data.durationMs != null ? `${data.durationMs}ms` : '';
tr.innerHTML = `
<td>${idx + 1}</td>
<td class="key" title="${escapeHtml(result.key)}">${escapeHtml(maskKey(result.key))}</td>
<td class="status">${pillFor(result)}</td>
<td class="reply ${reply ? '' : 'empty'}">${reply ? escapeHtml(reply) : (result.error ? escapeHtml(result.error) : (data.message || '—'))}</td>
<td class="duration">${duration}</td>
`;
return tr;
}
function updateRow(idx) {
const tr = els.tbody.querySelector(`tr[data-idx="${idx}"]`);
if (!tr) return;
tr.replaceWith(renderRow(idx, lastResults[idx]));
}
function updateSummary() {
const total = lastResults.length;
const ok = lastResults.filter((r) => r.data?.success).length;
const busy = lastResults.filter((r) => r.data?.upstreamBusy && !r.data?.success).length;
const fail = lastResults.filter((r) => !r.pending && !r.data?.success && !r.data?.upstreamBusy).length;
const pending = lastResults.filter((r) => r.pending).length;
els.summary.style.display = 'block';
els.summary.innerHTML = `
共 ${total} 个 Key:
<span class="pill pill-ok">✓ 可用 ${ok}</span>
<span class="pill pill-busy">⚠ 上游忙 ${busy}</span>
<span class="pill pill-err">✗ 不可用 ${fail}</span>
${pending ? `<span class="pill pill-pending">⏳ 进行中 ${pending}</span>` : ''}
`;
const anyOk = ok + busy > 0;
els.copyOkBtn.style.display = anyOk && pending === 0 ? '' : 'none';
els.exportBtn.style.display = total > 0 && pending === 0 ? '' : 'none';
}
async function runPool(tasks, concurrency) {
const queue = tasks.slice();
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length) {
const t = queue.shift();
if (!t) break;
await t();
}
});
await Promise.all(workers);
}
async function runAll() {
let baseUrl;
try { baseUrl = normalizeBaseUrl(els.baseUrl.value); }
catch (e) { alert(e.message); return; }
const keys = parseKeys(els.apiKeys.value);
const prompt = els.prompt.value;
const model = els.model.value.trim() || 'claude-opus-4-7';
const enable1m = !!els.enable1m.checked;
const concurrency = Math.max(1, Math.min(20, parseInt(els.concurrency.value, 10) || 5));
if (!prompt.trim() || keys.length === 0) {
alert('请填写至少一个 API Key 和测试文案');
return;
}
els.runBtn.disabled = true;
els.runBtn.textContent = `测试中 (0/${keys.length})...`;
els.copyOkBtn.style.display = 'none';
els.exportBtn.style.display = 'none';
lastResults = keys.map((k) => ({ key: k, pending: true }));
els.tbody.innerHTML = '';
lastResults.forEach((r, i) => els.tbody.appendChild(renderRow(i, r)));
els.table.style.display = 'table';
updateSummary();
let done = 0;
const tasks = keys.map((key, idx) => async () => {
try {
const data = await testKey({ baseUrl, apiKey: key, model, prompt, enable1m });
lastResults[idx] = { key, data };
} catch (err) {
lastResults[idx] = { key, error: err?.message || String(err) };
}
done += 1;
els.runBtn.textContent = `测试中 (${done}/${keys.length})...`;
updateRow(idx);
updateSummary();
});
await runPool(tasks, concurrency);
els.runBtn.disabled = false;
els.runBtn.textContent = '开始测试';
updateSummary();
}
function copyOkKeys() {
const ok = lastResults.filter((r) => r.data?.success || r.data?.upstreamBusy).map((r) => r.key);
if (!ok.length) return;
navigator.clipboard.writeText(ok.join('\n')).then(() => {
const orig = els.copyOkBtn.textContent;
els.copyOkBtn.textContent = `已复制 ${ok.length} 个`;
setTimeout(() => { els.copyOkBtn.textContent = orig; }, 1500);
});
}
function exportResults() {
const blob = new Blob([JSON.stringify(lastResults, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `claude-key-test-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
// ============================================================
// 模型下拉
// ============================================================
const modelCache = new Map();
let modelInflight = null;
let modelIds = [];
let activeIdx = -1;
function setModelHint(text, isErr = false) {
els.modelHint.textContent = text;
els.modelHint.style.color = isErr ? '#fca5a5' : '#94a3b8';
}
function renderModelPanel() {
els.modelPanel.innerHTML = '';
if (!modelIds.length) {
const empty = document.createElement('div');
empty.className = 'combo-empty';
empty.textContent = '聚焦输入框自动加载';
els.modelPanel.appendChild(empty);
return;
}
modelIds.forEach((id, i) => {
const item = document.createElement('div');
item.className = 'combo-item' + (i === activeIdx ? ' active' : '');
item.textContent = id;
item.addEventListener('mousedown', (e) => {
e.preventDefault();
els.model.value = id;
closeModelPanel();
});
els.modelPanel.appendChild(item);
});
}
function openModelPanel() { activeIdx = -1; renderModelPanel(); els.modelPanel.classList.add('open'); }
function closeModelPanel() { els.modelPanel.classList.remove('open'); }
async function loadModels({ force = false } = {}) {
let baseUrl;
try { baseUrl = normalizeBaseUrl(els.baseUrl.value); }
catch (e) { setModelHint(e.message, true); return; }
const firstKey = parseKeys(els.apiKeys.value)[0];
if (!firstKey) { setModelHint('请先填至少一个 API Key', true); return; }
const cacheKey = `${baseUrl}::${firstKey}`;
if (!force && modelCache.has(cacheKey)) {
modelIds = modelCache.get(cacheKey);
setModelHint(`已加载 ${modelIds.length} 个模型(缓存)`);
renderModelPanel();
return;
}
if (modelInflight) return;
setModelHint('拉取可用模型中…');
modelInflight = (async () => {
try {
const r = await listModels(baseUrl, firstKey);
if (r.ok && r.modelIds.length) {
modelIds = r.modelIds;
modelCache.set(cacheKey, modelIds);
setModelHint(`已加载 ${modelIds.length} 个模型`);
renderModelPanel();
} else {
setModelHint(`拉取失败:${extractErrMsg(r.body || r.rawText) || `HTTP ${r.status}`}`, true);
}
} catch (err) {
setModelHint(`请求异常:${err?.message || err}`, true);
} finally {
modelInflight = null;
}
})();
}
els.runBtn.addEventListener('click', runAll);
els.copyOkBtn.addEventListener('click', copyOkKeys);
els.exportBtn.addEventListener('click', exportResults);
els.prompt.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') runAll();
});
els.model.addEventListener('focus', () => { openModelPanel(); loadModels(); });
els.model.addEventListener('input', () => { activeIdx = -1; renderModelPanel(); els.modelPanel.classList.add('open'); });
els.model.addEventListener('blur', () => setTimeout(closeModelPanel, 120));
els.model.addEventListener('keydown', (e) => {
const items = els.modelPanel.querySelectorAll('.combo-item');
if (e.key === 'ArrowDown') { e.preventDefault(); activeIdx = Math.min(items.length - 1, activeIdx + 1); renderModelPanel(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIdx = Math.max(0, activeIdx - 1); renderModelPanel(); }
else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) { e.preventDefault(); els.model.value = items[activeIdx].textContent; closeModelPanel(); }
else if (e.key === 'Escape') { closeModelPanel(); }
});
els.baseUrl.addEventListener('change', () => { modelIds = []; setModelHint(''); });
els.apiKeys.addEventListener('change', () => { modelIds = []; setModelHint(''); });
</script>
</body>
</html>
1 个帖子 - 1 位参与者