[纯前端html] 模拟claude code的请求, 可用于批量测试any的key是否可用, 其它能支持cc调用理论上也可以

<!doctype html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Cl...
[纯前端html] 模拟claude code的请求, 可用于批量测试any的key是否可用, 其它能支持cc调用理论上也可以
[纯前端html] 模拟claude code的请求, 可用于批量测试any的key是否可用, 其它能支持cc调用理论上也可以

image

<!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-...&#10;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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 位参与者

阅读完整话题

来源: linux.do查看原文