// Skills —— 前端逻辑 // ChatGPT 官网多账号池:access_token 存本机 localStorage,前端自管理轮询/限流/恢复 // 请求方式:优先走本地中继(localhost:8765),回退到 Cloudflare Worker // =====【全局配置 · 放在最前避免 TDZ 死区】===== // 【用户预置 Key】—— 写死在代码里,跨设备自动生效,不用每台设备手动加 const BUILTIN_KEYS = [ { name: '🎁 我的智谱', provider: 'zhipu', key: 'e923eafcbf134ac3b8249f8e1b7b8d40.6gIuxGd27Aq4fRPc', model: 'auto', _builtin: true } ]; // 【默认提供商】启动时默认选中(chatsam / zhipu / chatgpt) const DEFAULT_PROVIDER = 'zhipu'; // ===== 商用密码门 ===== const ACCESS_PWD = 'skill2026'; // 默认密码,可改 function initLoginGate() { const gate = document.getElementById('loginGate'); const pwdInput = document.getElementById('loginPwd'); const btn = document.getElementById('loginBtn'); const errEl = document.getElementById('loginErr'); if (!gate || !pwdInput || !btn) return; // 已登录:加 authed class,隐藏登录页 if (sessionStorage.getItem('skill_auth') === '1') { document.documentElement.classList.add('authed'); return; } // 未登录:显示登录页 document.documentElement.classList.remove('authed'); const doLogin = () => { if (pwdInput.value === ACCESS_PWD) { sessionStorage.setItem('skill_auth', '1'); document.documentElement.classList.add('authed'); } else { errEl.hidden = false; pwdInput.value = ''; pwdInput.focus(); setTimeout(() => { errEl.hidden = true; }, 2000); } }; btn.addEventListener('click', doLogin); pwdInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); setTimeout(() => pwdInput.focus(), 100); } initLoginGate(); const RELAY_URL = 'http://localhost:8765'; const WORKER_URL = 'https://jc-skill-gpt.2297153835.workers.dev'; const DEPLOY_KEY = 'jc-skill-deploy-2026-secret'; let RELAY_OK = false; // 中继是否在线 async function checkRelay() { try { const r = await fetch(RELAY_URL + '/healthz', { signal: AbortSignal.timeout(2000) }); RELAY_OK = r.ok; } catch { RELAY_OK = false; } // 更新 UI const dot = $('relayDot'); const hint = $('relayHint'); const headerDot = $('relayStatus'); if (dot) { dot.className = 'relay-dot ' + (RELAY_OK ? 'online' : 'offline'); } if (hint) { hint.textContent = RELAY_OK ? '本地中继已连接 (localhost:8765),直连 chatgpt.com 无限制' : '本地中继未启动,正在使用 Cloudflare Worker(可能遇到 403 限流)'; } if (headerDot) { headerDot.className = 'relay-status ' + (RELAY_OK ? 'online' : 'offline'); headerDot.title = RELAY_OK ? '本地中继已连接' : '本地中继离线,走 Worker'; } return RELAY_OK; } // 返回当前可用的 base URL function apiBase() { return RELAY_OK ? RELAY_URL : WORKER_URL; } const PROVIDERS = { chatgpt: { name: 'ChatGPT 官网多账号池', defaultModel: 'gpt-4o', // chatgpt.com 网页端 8 个模型(探测时按这个优先级从最强到最弱) models: [ 'o3', 'gpt-4.1', 'gpt-4o', 'o1', 'gpt-4.1-mini', 'gpt-4o-mini', 'o3-mini', 'o1-mini' ], usesTokenPool: true, keyUrl: 'https://chatgpt.com', free: true, guide: { access: '通过本地中继或 Cloudflare Worker 调用 chatgpt.com', price: '免费,账号数 × ChatGPT 免费额度叠加', pay: '用你自己的 ChatGPT 账号', keyFormat: 'ChatGPT 官网 access_token(eyJ... 开头的 JWT)', steps: [ '打开 chatgpt.com 登录你的账号', 'F12 打开开发者工具 → Application → Cookies', '复制 __Secure-next-auth.session-token 的值(JWT 格式)', '或者用 https://chatgpt.com/api/auth/session 拿 access_token', '把 access_token 粘贴到下方「账号池」添加', '点击账号右侧的「🔍 探测」自动测出能跑哪些模型', '点击「📊 配额」查看账号 plan 和剩余信息' ], note: '⚠️ access_token 会过期(一般几天到几周),过期后该 token 状态变 disabled,请重新抓。多账号加进来自动轮询,单个限流/挂掉不影响其他。每个账号可独立选模型。' } }, zhipu: { name: '智谱 AI(GLM 系列模型轮调)', defaultModel: 'auto', // auto 表示自动轮调 models: ['auto', 'glm-4-flash', 'glm-4-air', 'glm-4-plus'], usesTokenPool: false, keyUrl: 'https://open.bigmodel.cn/api-keys', free: false, guide: { access: '国内直连,无需代理', price: '按 tokens 计费,赠送资源用完按量付费', pay: '在 open.bigmodel.cn 充值', keyFormat: '智谱 API Key(apikey.xxx 格式)', steps: [ '打开 open.bigmodel.cn 登录账号', '进入「API Keys」页面创建新的 Key', '复制 API Key(apikey.xxx 格式)', '粘贴到下方输入框添加', '系统自动跟踪模型余额,优先使用赠送资源' ], note: '💡 支持模型轮调:自动选择余额充足的模型,低于 10% 自动切换。显示各模型剩余 tokens 和使用进度。' } }, chatsam: { name: 'CHATSAM(内置号池 + 童锦程 Skill)', baseUrl: 'https://chanfasf-chatsam.hf.space', defaultModel: 'gpt-4o-mini', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'o1', 'o1-mini', 'o3', 'o3-mini'], usesTokenPool: false, noKeyRequired: true, // CHATSAM chatgpt2api 后端内置共享号池的公开 API Key chatSamApiKey: '34555', fallbackAuthKey: 'e923eafcbf134ac3b8249f8e1b7b8d40.6gIuxGd27Aq4fRPc', keyUrl: '', free: true, guide: { access: '直接使用 CHATSAM 内置号池,无需配置', price: '免费', pay: '', keyFormat: '', steps: [ '选择 CHATSAM 提供商即可使用', '号池由 CHATSAM 自动管理', '自动注入童锦程 Skill' ], note: '💡 无需获取 access_token,CHATSAM 已内置可用号池和最新的「童锦程话术体系 + 多话术选择模式」。后端是 chatgpt2api,需登录面板创建用户 Key(未配置则自动走智谱兜底)。' } } }; // 智谱模型轮调配置(赠送资源) const ZHIPU_MODEL_QUOTAS = { 'glm-4-flash': { total: 10000000, priority: 1, name: 'GLM-4-Flash (10M tokens)' }, 'glm-4-air': { total: 5000000, priority: 2, name: 'GLM-4-Air (5M tokens)' }, 'glm-4-plus': { total: 3000000, priority: 3, name: 'GLM-4-Plus (3M tokens)' } }; const MODEL_ROTATION_ORDER = ['glm-4-flash', 'glm-4-air', 'glm-4-plus']; // 智谱模型余额存储(localStorage) const ZHIPU_TOKENS_KEY = 'zhipu-model-tokens'; function loadZhipuTokens() { try { return JSON.parse(localStorage.getItem(ZHIPU_TOKENS_KEY) || '{}'); } catch { return {}; } } function saveZhipuTokens(tokens) { localStorage.setItem(ZHIPU_TOKENS_KEY, JSON.stringify(tokens)); } // 获取模型剩余 tokens(优先从 localStorage,没有则用总额) function getModelRemainingTokens(model) { const tokens = loadZhipuTokens(); if (tokens[model] !== undefined) return tokens[model]; return ZHIPU_MODEL_QUOTAS[model]?.total || 0; } // 更新模型剩余 tokens function updateModelTokens(model, usedTokens) { const tokens = loadZhipuTokens(); const remaining = Math.max(0, (tokens[model] || ZHIPU_MODEL_QUOTAS[model]?.total || 0) - usedTokens); tokens[model] = remaining; saveZhipuTokens(tokens); } // 获取推荐的智谱模型(余额 > 10%) function getRecommendedZhipuModel() { for (const model of MODEL_ROTATION_ORDER) { const remaining = getModelRemainingTokens(model); const total = ZHIPU_MODEL_QUOTAS[model]?.total || 1; if (remaining / total > 0.1) return model; } // 都低于 10% 了,还是返回第一个 return MODEL_ROTATION_ORDER[0]; } // ---------- DOM ---------- const $ = (id) => document.getElementById(id); const messagesEl = $('messages'); const inputEl = $('input'); const sendBtn = $('sendBtn'); const welcomeEl = $('welcome'); const settingsModal = $('settingsModal'); const providerSel = $('provider'); const getKeyLink = $('getKeyLink'); const providerGuideEl = $('providerGuide'); const keyListEl = $('keyList'); const newKeyNameInput = $('newKeyName'); const newKeyValInput = $('newKeyVal'); const addKeyBtn = $('addKeyBtn'); const dashBody = $('dashBody'); const refreshAllBtn = $('refreshAllBtn'); // ---------- 状态 ---------- const STORE_KEY = 'tong-jincheng-settings'; const HISTORY_KEY = 'tong-jincheng-history'; const GIRLS_KEY = 'jc-skill-girls'; let settings = loadSettings(); let history = loadHistory(); // [{role, content, girlId?, image?}] let girls = loadGirls(); // [{id, name, color, profile, emotion, lastSeen, createdAt}] let isStreaming = false; let keyRotationIdx = {}; // {provider: idx} 当前轮询到第几个 key let pendingImage = null; // data URL,待发送的截图 // 女生档案 const GIRL_COLORS = ['#ff6b8a', '#ffa94d', '#69db7c', '#4dabf7', '#cc5de8', '#fab005', '#20c997', '#f783ac']; function loadGirls() { try { return JSON.parse(localStorage.getItem(GIRLS_KEY) || '[]'); } catch { return []; } } function saveGirls() { localStorage.setItem(GIRLS_KEY, JSON.stringify(girls)); } function findGirlByName(name) { if (!name) return null; const n = name.trim(); return girls.find((g) => g.name === n) || null; } function addGirl(name, opts = {}) { const g = { id: 'g' + Date.now() + Math.random().toString(36).slice(2, 6), name: name.trim(), color: GIRL_COLORS[girls.length % GIRL_COLORS.length], profile: opts.profile || '', emotion: opts.emotion || '', lastSeen: Date.now(), createdAt: Date.now() }; girls.push(g); saveGirls(); return g; } function touchGirl(id, emotion) { const g = girls.find((x) => x.id === id); if (!g) return; g.lastSeen = Date.now(); if (emotion) g.emotion = emotion; saveGirls(); } function deleteGirl(id) { girls = girls.filter((g) => g.id !== id); saveGirls(); } // 自动清理:超过 5 个月没互动(没发截图、没发消息)的女生档案自动清空 const STALE_MS = 5 * 30 * 24 * 60 * 60 * 1000; // 约 5 个月 function cleanStaleGirls() { const now = Date.now(); const before = girls.length; girls = girls.filter((g) => g.lastSeen && (now - g.lastSeen) < STALE_MS); if (girls.length !== before) { saveGirls(); console.log(`[自动清理] 移除了 ${before - girls.length} 个超过 5 个月未互动的女生档案`); } } // 【全局 BUILTIN_KEYS / DEFAULT_PROVIDER 已在文件顶部声明,避免 TDZ 死区】 function loadSettings() { try { const s = JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); let keys = Array.isArray(s.keys) ? s.keys : []; if (s.apiKey && !keys.some((k) => k.provider === (s.provider || DEFAULT_PROVIDER))) { keys.push({ name: '默认', key: s.apiKey, provider: s.provider || DEFAULT_PROVIDER }); } // 合并内置 Key:按 (provider,key,name) 去重,保留用户状态字段 for (const bk of BUILTIN_KEYS) { if (!keys.some((k) => k.provider === bk.provider && k.key === bk.key)) { keys.push({ ...bk, status: 'ok' }); } else { // 已存在则打上 builtin 标记,防止被删后复原 const hit = keys.find((k) => k.provider === bk.provider && k.key === bk.key); hit._builtin = true; } } return { provider: s.provider || DEFAULT_PROVIDER, keys, baseUrl: s.baseUrl || '' }; } catch { return { provider: DEFAULT_PROVIDER, keys: BUILTIN_KEYS.map((k) => ({ ...k, status: 'ok' })), baseUrl: '' }; } } function saveSettings() { localStorage.setItem(STORE_KEY, JSON.stringify(settings)); } // 按 provider 取该平台的所有 key/token // token 池有 status 状态机:ok / limited / disabled function keysFor(provider) { const now = Date.now(); return settings.keys .filter((k) => k.provider === provider) .map((k) => { // 自动恢复:限流已过 resetAt → 恢复 ok if (k.status === 'limited' && k.resetAt && k.resetAt <= now) { k.status = 'ok'; k.resetAt = 0; } return k; }); } // 取下一个可用 key/token(ok 状态;跳过 limited/disabled) function nextKey(provider) { const ks = keysFor(provider).filter((k) => (k.status || 'ok') === 'ok'); if (ks.length === 0) return null; if (keyRotationIdx[provider] === undefined) keyRotationIdx[provider] = 0; const key = ks[keyRotationIdx[provider] % ks.length]; keyRotationIdx[provider] = (keyRotationIdx[provider] + 1) % ks.length; return key; } function loadHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } } function persistHistory() { // 只保留最近 40 条;超过 6 条之前的图片数据丢掉(转占位),避免 localStorage 爆 const trimmed = history.slice(-40).map((m, i, arr) => { if (m.image && i < arr.length - 6) { const copy = { ...m }; delete copy.image; copy.content = (m.content || '') + ' [图片已省略]'; return copy; } return m; }); try { localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); } catch { // 配额超了:丢掉所有图片再试 const noImg = trimmed.map((m) => { const c = { ...m }; delete c.image; return c; }); try { localStorage.setItem(HISTORY_KEY, JSON.stringify(noImg)); } catch {} } } // ---------- 快速提供商切换器(顶部 Header) ---------- const quickProvSwitch = $('quickProvSwitch'); function syncQuickSwitchFromSettings() { if (quickProvSwitch && settings.provider && quickProvSwitch.value !== settings.provider) { quickProvSwitch.value = settings.provider; } } if (quickProvSwitch) { quickProvSwitch.addEventListener('change', () => { const p = quickProvSwitch.value; if (!p || p === settings.provider) return; settings.provider = p; saveSettings(); // 同步设置面板里的 providerSel if (providerSel && providerSel.value !== p) providerSel.value = p; updateProviderUI(); renderKeyList(); renderDash(); const name = quickProvSwitch.options[quickProvSwitch.selectedIndex]?.textContent?.split(' ')[1] || p; toast(`已切换:${name}`); }); } // ---------- 初始化 UI ---------- function applySettingsToUI() { providerSel.value = settings.provider; syncQuickSwitchFromSettings(); updateProviderUI(); renderKeyList(); } function updateProviderUI() { const p = PROVIDERS[settings.provider]; if (!p) return; getKeyLink.href = p.keyUrl || '#'; getKeyLink.textContent = p.keyUrl ? `→ 去${p.name}获取 Key` : `→ ${p.name}(无需配置)`; if (!p.keyUrl) getKeyLink.style.display = 'none'; else getKeyLink.style.display = ''; // 自定义 base url 字段 const baseUrlInput = $('baseUrl'); const baseUrlField = $('baseUrlField'); if (p.requiresBaseUrl) { baseUrlField.style.display = ''; baseUrlInput.value = settings.baseUrl || ''; } else { baseUrlField.style.display = 'none'; } // CHATSAM 免 key:隐藏账号池+模型选择区域 const noKeyNeeded = p.noKeyRequired; const keyListField = $('keyField') || keyListEl.closest('.field'); const dashField = dashBody.closest('.field'); const newKeyRow = $('keyAddRow'); if (noKeyNeeded) { if (keyListField) keyListField.style.display = 'none'; if (dashField) dashField.style.display = 'none'; if (newKeyRow) newKeyRow.style.display = 'none'; } else { if (keyListField) keyListField.style.display = ''; if (dashField) dashField.style.display = ''; if (newKeyRow) newKeyRow.style.display = ''; // 根据 provider 切换 label / placeholder / hint const labelEl = $('keyFieldLabel'); const valInput = newKeyValInput; const hintTip = $('keyHintTip'); if (provider === 'zhipu') { if (labelEl) labelEl.textContent = '智谱 API Key(支持多 Key 轮询)'; if (valInput) valInput.placeholder = '粘贴智谱 API Key(apikey.xxx 格式)'; if (hintTip) hintTip.textContent = '在 open.bigmodel.cn 创建 API Key。支持多 Key 轮询,自动跟踪模型余额。Key 存本机 localStorage,不上传服务器。'; } else if (provider === 'chatgpt') { if (labelEl) labelEl.textContent = 'ChatGPT 账号池(多 access_token 自动轮询)'; if (valInput) valInput.placeholder = 'eyJ... 开头的 access_token'; if (hintTip) hintTip.textContent = '多账号 token 都加进来,自动轮询;单账号限流/失效不影响其他。Token 存本机 localStorage,不上传服务器。'; } else { if (labelEl) labelEl.textContent = 'API Key'; if (valInput) valInput.placeholder = '粘贴 API Key / access_token'; if (hintTip) hintTip.textContent = '多账号轮询,单账号限流自动切换。'; } } // 模型下拉框根据 provider 切换 updateModelSelect(p); renderProviderGuide(p); renderZhipuQuotas(); } // 切换模型下拉框选项 function updateModelSelect(p) { const sel = $('newKeyModel'); if (!sel) return; if (!p || !p.models) { sel.innerHTML = ''; return; } sel.innerHTML = p.models.map((m) => ``).join(''); if (p.defaultModel && p.models.includes(p.defaultModel)) sel.value = p.defaultModel; } function renderProviderGuide(p) { if (!p.guide) { providerGuideEl.innerHTML = ''; return; } const g = p.guide; const accessTag = g.access.startsWith('国内') ? `${g.access}` : `${g.access}`; const freeTag = p.free ? `免费` : ''; const steps = g.steps.map((s, i) => `
  • ${i + 1}${s}
  • `).join(''); providerGuideEl.innerHTML = `
    ${p.name} · API 获取说明 ${freeTag}${accessTag}
    价格${g.price}
    充值${g.pay}
    Key 格式${g.keyFormat}
    `; } // 渲染智谱模型余额显示面板 function renderZhipuQuotas() { const quotaEl = $('zhipuQuotas'); if (!quotaEl) return; // 只在智谱提供商时显示 if (settings.provider !== 'zhipu') { quotaEl.style.display = 'none'; return; } quotaEl.style.display = ''; // 渲染各模型余额进度条 const models = MODEL_ROTATION_ORDER.map((model) => { const total = ZHIPU_MODEL_QUOTAS[model]?.total || 1; const remaining = getModelRemainingTokens(model); const percentage = Math.max(0, Math.min(100, (remaining / total) * 100)); const percentageClass = percentage > 50 ? 'quota-high' : percentage > 10 ? 'quota-mid' : 'quota-low'; const recommended = getRecommendedZhipuModel() === model; return `
    ${ZHIPU_MODEL_QUOTAS[model]?.name || model} ${recommended ? '推荐' : ''}
    剩余 ${formatTokens(remaining)} / ${formatTokens(total)} ${percentage.toFixed(1)}%
    `; }).join(''); quotaEl.innerHTML = `
    模型余额监控

    自动轮调:优先使用余额充足的模型,低于 10% 自动切换

    ${models}
    `; } // 格式化 tokens 显示(带单位) function formatTokens(num) { if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; return num.toString(); } function renderKeyList() { const ks = keysFor(settings.provider); const p = PROVIDERS[settings.provider]; const isPool = p && p.usesTokenPool; // CHATSAM 免 key 模式:不渲染列表 if (p && p.noKeyRequired) { keyListEl.innerHTML = '
    ✅ CHATSAM 内置号池已就绪,无需添加账号
    '; renderDash(); return; } // 每个 token 可用模型列表(探测后存在 k.probedModels) // 未探测时显示全模型 const modelOptions = (p && p.models) ? p.models : (p && p.defaultModel ? [p.defaultModel] : []); const emptyTip = settings.provider === 'zhipu' ? '还没添加智谱 API Key,去 open.bigmodel.cn 创建后粘贴到下方' : settings.provider === 'chatgpt' ? '还没添加账号,去 chatgpt.com 抓 access_token 加进来' : '还没添加 API Key'; keyListEl.innerHTML = (ks.length === 0) ? `
    ${emptyTip}
    ` : ks.map((k, i) => { const status = k.status || 'ok'; const statusLabel = status === 'ok' ? '正常' : status === 'limited' ? `限流${k.resetAt ? ' · ' + formatCountdown(k.resetAt) : ''}` : '失效'; const errorTip = k.lastError ? ` title="${escapeHtml(k.lastError)}"` : ''; // 可用模型列表:智谱永远展示全部模型(探测只是验证,不做限制) // ChatGPT:探测过的用 probedModels,否则全展示 let avail; if (settings.provider === 'zhipu') { avail = modelOptions; // 智谱所有模型都展示,不被探测结果限制 } else { avail = Array.isArray(k.probedModels) && k.probedModels.length ? k.probedModels : modelOptions; } // 如果 probedModels 是空数组(旧逻辑探测失败留下的脏数据),智谱直接清掉状态 if (settings.provider === 'zhipu' && Array.isArray(k.probedModels) && k.probedModels.length === 0) { delete k.probedModels; if (k.status === 'disabled') { k.status = 'ok'; k.lastError = ''; } } const useModel = (avail.includes(k.model)) ? k.model : (k.model || (p && p.defaultModel) || 'gpt-4o'); // 智谱/ChatGPT 都显示模型选择 const modelSelect = avail.length ? `` : ''; const plan = k.sessionInfo && k.sessionInfo.planType ? `${k.sessionInfo.planType}` : ''; const tabBadge = ''; const tabBtn = ''; // 探测/session 按钮仅 ChatGPT 显示 const probeBtn = isPool ? `` : ''; const sessionBtn = isPool ? `` : ''; return `
    ${escapeHtml(k.name || '未命名')} ${escapeHtml(maskKey(k.key))} ${modelSelect} ${plan} ${statusLabel} ${probeBtn} ${sessionBtn} ${status === 'limited' ? `` : ''}
    `; }).join(''); keyListEl.querySelectorAll('.key-del').forEach((btn) => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx, 10); const ks = keysFor(settings.provider); const target = ks[idx]; settings.keys = settings.keys.filter((k) => k !== target); saveSettings(); renderKeyList(); }); }); keyListEl.querySelectorAll('.key-reset').forEach((btn) => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx, 10); const ks = keysFor(settings.provider); const target = ks[idx]; target.status = 'ok'; target.resetAt = 0; target.lastError = ''; saveSettings(); renderKeyList(); toast(`已重置账号「${target.name}」`); }); }); keyListEl.querySelectorAll('.key-model-sel').forEach((sel) => { sel.addEventListener('change', () => { const idx = parseInt(sel.dataset.idx, 10); const ks = keysFor(settings.provider); const target = ks[idx]; if (!target) return; target.model = sel.value; saveSettings(); toast(`账号「${target.name}」→ ${sel.value}`); }); }); keyListEl.querySelectorAll('.key-probe').forEach((btn) => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx, 10); const ks = keysFor(settings.provider); const target = ks[idx]; if (target) probeOneToken(target); }); }); keyListEl.querySelectorAll('.key-session').forEach((btn) => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx, 10); const ks = keysFor(settings.provider); const target = ks[idx]; if (target) fetchSessionInfo(target); }); }); renderDash(); } // 探测单个 token:按优先级串行测,找到最强可用的就停 async function probeOneToken(keyObj) { const provider = settings.provider; const p = PROVIDERS[provider]; const idx = findIdxByKey(keyObj); btnSpin(`.key-probe[data-idx="${idx}"]`, true); btnSpin(`.dash-probe[data-idx="${idx}"]`, true); // ===== 智谱:直连智谱 API 测三个 glm 模型(跳过 auto,因为它是逻辑模型)===== if (provider === 'zhipu') { toast(`正在验证「${keyObj.name}」...`); const testModels = ['glm-4-plus', 'glm-4-air', 'glm-4-flash']; const results = []; let firstOkModel = null; const t0 = Date.now(); for (const m of testModels) { try { const resp = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${keyObj.key}` }, body: JSON.stringify({ model: m, messages: [{ role: 'user', content: '你好' }], stream: false, max_tokens: 3 }), signal: AbortSignal.timeout(12000) }); const ok = resp.ok; const status = resp.status; let error = ''; if (!ok) { try { const d = await resp.json(); error = d.error?.message || d.msg || d.error || `HTTP ${status}`; } catch { error = `HTTP ${status}`; } } else { firstOkModel = firstOkModel || m; } results.push({ model: m, ok, status, error }); } catch (e) { results.push({ model: m, ok: false, status: 0, error: String(e.message || e) }); } } const okList = results.filter((r) => r.ok).map((r) => r.model); // auto 永远加回 probedModels(因为 auto 是默认模型选择) const probed = okList.length ? ['auto', ...okList] : []; keyObj.probedModels = probed; keyObj.probeSummary = { tested: results.length, totalModels: testModels.length, totalMs: Date.now() - t0, results }; if (okList.length === 0) { // ⚠️ 智谱:探测失败不打 disabled!探测使用的是非流式+短超时, // 可能因网络/CORS/参数波动误判;真实发送时streamChat再决定是否禁用 keyObj.failCount = (keyObj.failCount || 0) + 1; const lastErr = (results[0] && results[0].error) || '模型验证未通过'; keyObj.lastError = lastErr; // 不设置 status = disabled,保持默认 ok,发送时再校验 saveSettings(); renderKeyList(); const errs = results.map((f) => `${f.model}=${f.status || 'err'}`).join(', '); toast(`⚠️ 「${keyObj.name}」探测未完成 (${errs.slice(0,40)}),直接发送试一下`, 5500); } else { keyObj.model = firstOkModel; keyObj.status = 'ok'; keyObj.successCount = (keyObj.successCount || 0) + 1; saveSettings(); renderKeyList(); toast(`✅ 「${keyObj.name}」可用:${okList.join(' / ')}(${Date.now() - t0}ms)`, 4500); } btnSpin(`.key-probe[data-idx="${findIdxByKey(keyObj)}"]`, false); btnSpin(`.dash-probe[data-idx="${findIdxByKey(keyObj)}"]`, false); renderDash(); return; } // ===== ChatGPT:走本地中继或 Worker 的 /probe 端点 ===== toast(`正在探测「${keyObj.name}」最强可用模型...`); try { const models = (p && p.models) || []; const base = apiBase(); const headers = { 'Content-Type': 'application/json', 'X-Account-Token': keyObj.key }; if (!RELAY_OK) headers['Authorization'] = `Bearer ${DEPLOY_KEY}`; const resp = await fetch(base + '/probe', { method: 'POST', headers, body: JSON.stringify({ models }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); // firstOk 是按优先级找出的第一个可用的(最强) const firstOkModel = data.firstOk && (data.firstOk.realModel || data.firstOk.model); // okModels 是所有跑过且成功的(实际上现在通常只有 1 个) const okList = (data.okModels || []).filter((m) => models.includes(m)); // 但用户往往想用次强做多模型轮询——所以仍然把"理论可用"集合存下来 // 我们用 probedModels = [firstOk, ...其他已成功测过的] // 如果全失败,probedModels = [] → 该 token 等于废 keyObj.probedModels = okList; keyObj.lastProbedAt = Date.now(); // 探测结果摘要 keyObj.probeSummary = { firstOk: firstOkModel || null, tested: data.tested, totalModels: data.totalModels, totalMs: data.totalMs, results: data.results }; // 统计本次结果 const failed = (data.results || []).filter((r) => !r.ok); if (okList.length === 0) { keyObj.failCount = (keyObj.failCount || 0) + 1; if (failed.length) { keyObj.status = 'disabled'; keyObj.lastError = failed[0].error || `HTTP ${failed[0].status}`; } saveSettings(); renderKeyList(); const errs = failed.map((f) => `${f.model}=${f.status || 'err'}`).join(', '); toast(`❌ 「${keyObj.name}」无可用模型 (${errs})`, 5000); } else { // 自动切到最强可用 keyObj.model = firstOkModel; keyObj.successCount = (keyObj.successCount || 0) + 1; saveSettings(); renderKeyList(); if (data.tested === 1) { toast(`✅ 「${keyObj.name}」最强可用:${firstOkModel}(一发入魂 · ${data.totalMs}ms)`, 4000); } else { toast(`✅ 「${keyObj.name}」最强可用:${firstOkModel}(试了 ${data.tested} 个 · ${data.totalMs}ms)`, 5000); } } } catch (e) { keyObj.failCount = (keyObj.failCount || 0) + 1; keyObj.lastError = String(e.message || e); saveSettings(); renderKeyList(); toast(`❌ 探测失败:${e.message}`, 4000); } finally { btnSpin(`.key-probe[data-idx="${findIdxByKey(keyObj)}"]`, false); } } // 拉取账号 session 信息 async function fetchSessionInfo(keyObj) { const provider = settings.provider; const idx = findIdxByKey(keyObj); btnSpin(`.key-session[data-idx="${idx}"]`, true); btnSpin(`.dash-session[data-idx="${idx}"]`, true); // ===== 智谱:查资源信息 ===== if (provider === 'zhipu') { toast(`正在查询「${keyObj.name}」资源信息...`); try { const resp = await fetch('https://open.bigmodel.cn/api/monitor/usage/quota/limit', { method: 'GET', headers: { 'Authorization': `Bearer ${keyObj.key}` }, signal: AbortSignal.timeout(10000) }); const data = await resp.json(); if (data.success && data.data) { const q = data.data; const planTxt = q.totalQuota ? `总量${(q.totalQuota/10000).toFixed(1)}万` : '按量'; keyObj.sessionInfo = { planType: planTxt, email: q.userName || '', name: q.userCode || '', expires: q.quotaExpireTime ? new Date(q.quotaExpireTime).toISOString().slice(0,10) : '', fetchedAt: Date.now() }; saveSettings(); renderKeyList(); renderDash(); const remainTxt = q.remainingQuota != null ? ` · 剩余${(q.remainingQuota/10000).toFixed(1)}万tokens` : ''; toast(`💎 ${keyObj.name}:${planTxt}${remainTxt}`, 5000); } else { keyObj.sessionInfo = { planType: '智谱 API', email: '有效', name: keyObj.name, fetchedAt: Date.now() }; saveSettings(); renderKeyList(); renderDash(); toast(`🔑 ${keyObj.name}:Key 有效(暂无资源详情权限)`, 4500); } } catch (e) { toast(`ℹ️ ${keyObj.name}:Key已添加,直接发送即可(${e.message || '资源查询需管理权限'})`, 5000); } finally { const i2 = findIdxByKey(keyObj); btnSpin(`.key-session[data-idx="${i2}"]`, false); btnSpin(`.dash-session[data-idx="${i2}"]`, false); } return; } // ===== ChatGPT:走中继/worker /session ===== toast(`正在拉取「${keyObj.name}」账号信息...`); try { const base = apiBase(); const headers = { 'X-Account-Token': keyObj.key }; if (!RELAY_OK) headers['Authorization'] = `Bearer ${DEPLOY_KEY}`; const resp = await fetch(base + '/session', { method: 'GET', headers }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); if (data.error) throw new Error(data.error); keyObj.sessionInfo = { planType: data.planType || 'free', email: data.email, name: data.name, expires: data.expires, fetchedAt: Date.now() }; if (data.expires) { const expMs = Date.parse(data.expires); if (Number.isFinite(expMs) && expMs < Date.now() + 2 * 86400_000) { keyObj.status = 'disabled'; keyObj.lastError = 'token 即将过期(< 2 天)'; toast(`⚠️ 账号「${keyObj.name}」token 即将过期(${data.expires})`, 5000); } } saveSettings(); renderKeyList(); renderDash(); toast(`📊 ${keyObj.name}:${data.email || ''} · ${data.planType || 'free'} · 过期 ${data.expires ? data.expires.slice(0,10) : '?'}`, 5000); } catch (e) { toast(`❌ 拉取失败:${e.message}`, 4000); } finally { const i2 = findIdxByKey(keyObj); btnSpin(`.key-session[data-idx="${i2}"]`, false); btnSpin(`.dash-session[data-idx="${i2}"]`, false); } } function findIdxByKey(keyObj) { const ks = keysFor(settings.provider); return ks.indexOf(keyObj); } // 账号仪表盘(类型 / 状态 / 账号信息 / 额度 / 恢复时间 / 成功 / 失败 / 操作) function renderDash() { const ks = keysFor(settings.provider); if (ks.length === 0) { dashBody.innerHTML = '还没添加账号'; return; } dashBody.innerHTML = ks.map((k, i) => { const status = k.status || 'ok'; const statusCell = status === 'ok' ? '正常' : status === 'limited' ? '限流' : '失效'; // 类型 = 订阅 planType(未拉取过显示 ?) const type = k.sessionInfo && k.sessionInfo.planType ? `${k.sessionInfo.planType}` : '未检测'; // 账号信息 = 邮箱 + 名字 const acct = k.sessionInfo ? `${escapeHtml(k.sessionInfo.email || k.sessionInfo.name || '?')}
    ${escapeHtml(k.name || '')}` : `未拉取`; // 额度 = 探测结果摘要(可用模型数 / 总数 + 当前模型) const ps = k.probeSummary; const quota = ps ? `可用 ${ps.tested === ps.totalModels ? '0' : (ps.totalModels - (ps.results.filter((r) => !r.ok).length))}/${ps.totalModels}
    当前:${escapeHtml(k.model || '?')}` : (k.probedModels && k.probedModels.length ? `可用 ${k.probedModels.length}
    当前:${escapeHtml(k.model || '?')}` : '未探测'); // 恢复时间 const resetCell = k.resetAt && status === 'limited' ? `${formatCountdown(k.resetAt)}` : (k.sessionInfo && k.sessionInfo.expires ? `token
    ${k.sessionInfo.expires.slice(0, 10)}` : ''); const ok = k.successCount || 0; const fail = k.failCount || 0; return ` ${type} ${statusCell} ${acct} ${quota} ${resetCell} ${ok} ${fail} `; }).join(''); // 事件 dashBody.querySelectorAll('.dash-probe').forEach((b) => b.addEventListener('click', () => { const idx = parseInt(b.dataset.idx, 10); const k = keysFor(settings.provider)[idx]; if (k) probeOneToken(k); })); dashBody.querySelectorAll('.dash-session').forEach((b) => b.addEventListener('click', () => { const idx = parseInt(b.dataset.idx, 10); const k = keysFor(settings.provider)[idx]; if (k) fetchSessionInfo(k); })); dashBody.querySelectorAll('.dash-reset').forEach((b) => b.addEventListener('click', () => { const idx = parseInt(b.dataset.idx, 10); const k = keysFor(settings.provider)[idx]; if (!k) return; k.status = 'ok'; k.resetAt = 0; k.lastError = ''; saveSettings(); renderKeyList(); renderDash(); toast(`已重置「${k.name}」`); })); dashBody.querySelectorAll('.dash-del').forEach((b) => b.addEventListener('click', () => { const idx = parseInt(b.dataset.idx, 10); const ks = keysFor(settings.provider); const target = ks[idx]; if (!target) return; if (!confirm(`删除账号「${target.name}」?`)) return; settings.keys = settings.keys.filter((k) => k !== target); saveSettings(); renderKeyList(); renderDash(); })); } function btnSpin(selector, on) { const el = keyListEl.querySelector(selector); if (!el) return; if (on) { el.dataset.spin = '1'; el.style.opacity = '0.5'; } else { delete el.dataset.spin; el.style.opacity = ''; } } function formatCountdown(ts) { const sec = Math.max(0, Math.floor((ts - Date.now()) / 1000)); if (sec < 60) return sec + 's'; if (sec < 3600) return Math.floor(sec / 60) + 'm'; return Math.floor(sec / 3600) + 'h'; } function maskKey(key) { if (!key) return ''; if (key.length <= 10) return key.slice(0, 3) + '***'; return key.slice(0, 5) + '***' + key.slice(-4); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } providerSel.addEventListener('change', () => { settings.provider = providerSel.value; updateProviderUI(); renderKeyList(); }); addKeyBtn.addEventListener('click', async () => { const name = newKeyNameInput.value.trim() || `账号${keysFor(settings.provider).length + 1}`; const key = newKeyValInput.value.trim(); if (!key) { toast('Key 不能为空'); return; } const p = PROVIDERS[settings.provider]; const modelSel = $('newKeyModel'); const model = (modelSel && modelSel.value) || (p && p.defaultModel) || 'gpt-4o'; const newKey = { name, key, provider: settings.provider, model, addedAt: Date.now(), status: 'ok' }; settings.keys.push(newKey); saveSettings(); newKeyNameInput.value = ''; newKeyValInput.value = ''; renderKeyList(); // ChatGPT 走探测;智谱直接可用 if (settings.provider === 'chatgpt') { toast(`已添加「${name}」,自动探测可用模型...`); probeOneToken(newKey); } else if (settings.provider === 'zhipu') { toast(`✅ 已添加「${name}」,可直接发送`); } else { toast(`已添加「${name}」`); } }); // 批量刷新所有账号:先 session 后 probe refreshAllBtn.addEventListener('click', async () => { const ks = keysFor(settings.provider); if (ks.length === 0) { toast('还没添加账号'); return; } refreshAllBtn.disabled = true; refreshAllBtn.textContent = '⏳ 刷新中...'; try { for (const k of ks) { try { await fetchSessionInfo(k); } catch {} } for (const k of ks) { try { await probeOneToken(k); } catch {} } toast(`✅ 已刷新 ${ks.length} 个账号`); } finally { refreshAllBtn.disabled = false; refreshAllBtn.textContent = '🔄 全部刷新'; renderDash(); } }); newKeyValInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addKeyBtn.click(); }); $('settingsBtn').addEventListener('click', () => { applySettingsToUI(); renderGirlsPanel(); settingsModal.hidden = false; }); $('closeSettings').addEventListener('click', () => settingsModal.hidden = true); settingsModal.querySelector('.modal-mask').addEventListener('click', () => settingsModal.hidden = true); $('saveSettings').addEventListener('click', () => { settings.provider = providerSel.value; const baseUrlInput = $('baseUrl'); settings.baseUrl = baseUrlInput ? baseUrlInput.value.trim() : ''; saveSettings(); settingsModal.hidden = true; if (keysFor(settings.provider).length === 0) { toast('还没填 API Key,点右上角设置填一下才能用'); } else { toast('已保存'); } }); $('clearChat').addEventListener('click', () => { if (!confirm('清空所有对话记录?')) return; history = []; persistHistory(); renderHistory(); welcomeEl.style.display = ''; settingsModal.hidden = true; }); // 复制中继启动命令 const copyRelayBtn = $('copyRelayCmd'); if (copyRelayBtn) { copyRelayBtn.addEventListener('click', () => { const cmd = $('relayCmd'); if (cmd) { navigator.clipboard.writeText(cmd.textContent).then(() => toast('已复制启动命令')).catch(() => toast('复制失败')); } }); } // ---------- 渲染 ---------- function renderHistory() { // 清掉旧消息气泡(保留 welcome) [...messagesEl.querySelectorAll('.msg')].forEach((n) => n.remove()); if (history.length === 0) { welcomeEl.style.display = ''; return; } welcomeEl.style.display = 'none'; history.forEach((m) => appendBubble(m.role, m.content, false, m.image, m.girlId)); scrollToBottom(); } // 给某条消息节点附加女生标签(加到 bubble 内部,避免破坏横向布局) function attachGirlTag(wrap, girlId) { const g = girls.find((x) => x.id === girlId); if (!g) return; const bubble = wrap.querySelector('.bubble'); if (!bubble || bubble.querySelector('.girl-tag')) return; const tag = document.createElement('span'); tag.className = 'girl-tag'; tag.style.color = g.color; tag.style.borderColor = g.color; tag.textContent = g.name; bubble.appendChild(tag); } function appendBubble(role, content, animate = true, image = null, girlId = null) { const wrap = document.createElement('div'); wrap.className = `msg ${role}`; if (!animate) wrap.style.animation = 'none'; const av = document.createElement('div'); av.className = 'avatar-mini'; av.textContent = role === 'user' ? '你' : 'S'; const bubble = document.createElement('div'); bubble.className = 'bubble'; if (image) { const img = document.createElement('img'); img.className = 'bubble-img'; img.src = image; img.alt = '聊天截图'; bubble.appendChild(img); } if (content) { const txt = document.createElement('div'); txt.textContent = content; bubble.appendChild(txt); } // 女生头像边框色(仅 bot) if (role === 'bot' && girlId) { const g = girls.find((x) => x.id === girlId); if (g) av.style.borderColor = g.color; } wrap.appendChild(av); wrap.appendChild(bubble); if (girlId) attachGirlTag(wrap, girlId); messagesEl.appendChild(wrap); scrollToBottom(); return bubble; } function scrollToBottom() { requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }); } function toast(msg, ms) { const t = document.createElement('div'); t.textContent = msg; t.style.cssText = 'position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom, 0px) + 80px);transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:20px;font-size:13px;z-index:100;opacity:0;transition:opacity .2s;max-width:90vw;text-align:center;line-height:1.5;'; document.body.appendChild(t); requestAnimationFrame(() => t.style.opacity = '1'); setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, ms || 1800); } // ---------- 输入框自适应高度 ---------- inputEl.addEventListener('input', () => { inputEl.style.height = 'auto'; inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px'; }); inputEl.addEventListener('keydown', (e) => { // 桌面端 Enter 发送,移动端回车换行(默认行为) if (e.key === 'Enter' && !e.shiftKey && window.matchMedia('(min-width: 600px)').matches) { e.preventDefault(); send(); } }); sendBtn.addEventListener('click', send); // 推荐问题 document.querySelectorAll('.chip').forEach((chip) => { chip.addEventListener('click', () => { inputEl.value = chip.dataset.q; inputEl.dispatchEvent(new Event('input')); send(); }); }); // ---------- 图片上传 ---------- const imgBtn = $('imgBtn'); const imgInput = $('imgInput'); const imgPreview = $('imgPreview'); function handleImageFile(file) { if (!file) return; if (!file.type.startsWith('image/')) { toast('只能发图片'); return; } if (file.size > 4 * 1024 * 1024) { toast('图片太大,请小于 4MB'); return; } const reader = new FileReader(); reader.onload = () => { pendingImage = reader.result; imgPreview.innerHTML = ''; const im = document.createElement('img'); im.src = pendingImage; const rm = document.createElement('button'); rm.className = 'img-preview-rm'; rm.innerHTML = ''; rm.addEventListener('click', clearImagePreview); imgPreview.appendChild(im); imgPreview.appendChild(rm); imgPreview.style.display = 'flex'; }; reader.readAsDataURL(file); } function clearImagePreview() { pendingImage = null; imgPreview.innerHTML = ''; imgPreview.style.display = 'none'; imgInput.value = ''; } if (imgBtn) { imgBtn.addEventListener('click', () => imgInput.click()); imgInput.addEventListener('change', () => handleImageFile(imgInput.files[0])); } // 支持粘贴图片 document.addEventListener('paste', (e) => { const items = e.clipboardData?.items || []; for (const it of items) { if (it.type.startsWith('image/')) { handleImageFile(it.getAsFile()); break; } } }); // ---------- 女生档案面板 ---------- const girlsListEl = $('girlsList'); function renderGirlsPanel() { if (!girlsListEl) return; if (girls.length === 0) { girlsListEl.innerHTML = '

    还没有记录任何女生。发张聊天截图或贴段资料,会自动建档。

    '; return; } girlsListEl.innerHTML = girls.map((g) => { const last = g.lastSeen ? new Date(g.lastSeen).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '无'; return `
    ${escapeHtml(g.name)} ${escapeHtml(g.emotion || '')}
    最后互动:${last}
    `; }).join(''); girlsListEl.querySelectorAll('.girl-del').forEach((btn) => { btn.addEventListener('click', () => { const id = btn.dataset.id; deleteGirl(id); renderGirlsPanel(); toast('已删除该女生档案'); }); }); } // ---------- 发送 ---------- // 构造女生档案上下文(注入系统提示词) function buildGirlsContext() { if (girls.length === 0) { return '\n\n## 女生档案系统\n\n当前还没有记录任何女生。用户这次发来的可能是新女生。'; } const list = girls.map((g, i) => { const last = g.lastSeen ? new Date(g.lastSeen).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '无'; return `${i + 1}. ${g.name} | 情绪:${g.emotion || '未知'} | 资料:${g.profile || '暂无'} | 最后互动:${last}`; }).join('\n'); return `\n\n## 女生档案系统\n\n当前已记录的女生:\n${list}\n\n用户这次发来的可能是上面某个女生的消息/截图,也可能是新女生。你必须在回复最开头标注一行:\n[女生: 名字] [情绪: 这次消息反映的情绪]\n- 名字:能匹配到已记录的女生就用她的原名;新女生根据截图顶部名字或资料给她起个昵称\n- 情绪:根据这次消息判断她当前情绪状态(简短,如“有点累,求关注”“热情主动”“冷淡敷衍”)\n标注完这一行后再空一行,接着按正常输出格式回复。`; } // 从 AI 回复开头解析 [女生: x] [情绪: y] 标签 function parseGirlTags(text) { const m = text.match(/^\s*\[女生[::]\s*([^\]]+)\]\s*\[情绪[::]\s*([^\]]+)\]/); if (!m) return { name: '', emotion: '', clean: text }; const name = m[1].trim(); const emotion = m[2].trim(); const clean = text.slice(m[0].length).replace(/^\s+/, ''); return { name, emotion, clean }; } async function send() { const text = inputEl.value.trim(); const image = pendingImage; if ((!text && !image) || isStreaming) return; const p = PROVIDERS[settings.provider]; // CHATSAM 等免 key 平台直接放行 const noKeyNeeded = p && p.noKeyRequired; if (!noKeyNeeded && keysFor(settings.provider).length === 0) { toast('先点右上角设置,填 API Key'); settingsModal.hidden = false; return; } // 有图片:确保用支持视觉的 token if (image) { // CHATSAM 默认 gpt-4o 支持视觉,智谱需 glm-4v if (noKeyNeeded) { // CHATSAM 默认 gpt-4o,支持视觉 } else { const hasVision = keysFor(settings.provider).some((k) => { const m = k.model || (p && p.defaultModel) || 'gpt-4o'; return /^gpt-4|^o[13]|^glm-4v/i.test(m); }); if (!hasVision) { toast('请至少添加一个 gpt-4o/gpt-4.1/o1/o3/glm-4v 的 token 识别图片'); return; } } } welcomeEl.style.display = 'none'; // 用户气泡(带图片预览) appendBubble('user', text || '(截图)', true, image); history.push({ role: 'user', content: text, image: image || undefined }); inputEl.value = ''; inputEl.style.height = 'auto'; clearImagePreview(); persistHistory(); // 创建机器人气泡用于流式填充 const bubble = appendBubble('bot', ''); bubble.classList.add('streaming'); isStreaming = true; sendBtn.disabled = true; // 确保 Skill 已加载 if (!SKILL_READY) { await loadSkill(); } // 构造消息:系统提示 = SKILL + 女生档案上下文 const sysContent = (SKILL_PROMPT || '你是一个有用的助手。') + buildGirlsContext(); const msgs = [{ role: 'system', content: sysContent }]; for (const m of history) { const role = m.role === 'bot' ? 'assistant' : 'user'; // 历史消息里的图片:只保留最近这一条带图,更早的图不带(content 里已有文字占位) if (m.image && m === history[history.length - 1]) { // 当前最新用户消息:构造多模态 content const parts = []; if (m.content) parts.push({ type: 'text', text: m.content }); parts.push({ type: 'image_url', image_url: { url: m.image } }); msgs.push({ role, content: parts }); } else { msgs.push({ role, content: m.content || '' }); } } try { const full = await streamChatWithRetry(msgs, (delta) => { bubble.textContent += delta; scrollToBottom(); }); // 解析女生标签 const { name, emotion, clean } = parseGirlTags(full); let displayText = full; let girlId = null; if (name) { let g = findGirlByName(name); if (!g) g = addGirl(name, { emotion }); else touchGirl(g.id, emotion); girlId = g.id; displayText = clean; renderGirlsPanel(); } bubble.classList.remove('streaming'); bubble.textContent = displayText; // 给气泡加女生标签 if (girlId) attachGirlTag(bubble.parentElement, girlId); history.push({ role: 'bot', content: displayText, girlId }); persistHistory(); } catch (err) { bubble.classList.remove('streaming'); bubble.parentElement.classList.add('error'); bubble.textContent = '出错了:' + (err.message || err); } finally { isStreaming = false; sendBtn.disabled = false; } } // ---------- 多 Key 轮询调用(每个 token 自带 model) ---------- // 失败(401/403/429/5xx)时换下一个 Key;所有 Key 失败后抛出 async function streamChatWithRetry(messages, onDelta) { const provider = settings.provider; const p = PROVIDERS[provider]; if (!p) throw new Error('未知的模型服务商'); // CHATSAM 等免 key 平台:直接调用,不走轮询 if (p.noKeyRequired) { try { const res = await streamChat(p, 'use-pool', messages, onDelta, p.defaultModel, provider); return res; } catch (err) { throw err; } } const ks = keysFor(provider); if (ks.length === 0) { if (provider === 'chatgpt') { throw new Error('你还没添加 ChatGPT access_token(eyJ... 开头的 JWT)。不会抓的话,建议直接切到 🪙 CHATSAM 免Key 或 🧠 智谱 GLM,打开就能用'); } throw new Error('该平台没有配置 API Key'); } let lastErr = null; for (let attempt = 0; attempt < ks.length; attempt++) { const keyObj = nextKey(provider); if (!keyObj) break; let useModel = keyObj.model || (p && p.defaultModel) || 'gpt-4o'; // 智谱模型轮调:如果选择 auto,自动选择余额充足的模型 if (provider === 'zhipu' && useModel === 'auto') { useModel = getRecommendedZhipuModel(); } try { const res = await streamChat(p, keyObj.key, messages, onDelta, useModel, provider); markTokenOk(provider, keyObj); return res; } catch (err) { lastErr = err; const msg = String(err.message || err); // 智谱:只有真实返回 401/403/429 且是 HTTP 状态码才打标; // "Failed to fetch" / 网络错误 / CORS 问题 / 流式中断 等 不打标,让用户重试 const isNetworkErr = /failed to fetch|load failed|networkerror|abort|timeout|interrupted|chunk|stream/i.test(msg) || /undefined|TypeError|NetworkError/i.test(msg) || msg.length < 20; // 短错误一般不是 HTTP 错误 const shouldRetry = provider === 'zhipu' ? (!isNetworkErr && /(401|403|429|访问量过大|稍后再试|quota.?exceeded|insufficient|invalid.?api.?key)/i.test(msg)) : /401|403|429|rate.?limit|quota|insufficient|unauthorized|invalid.*key|1305|访问量过大|稍后再试/i.test(msg); if (shouldRetry) { const status = /401|403|unauthorized|invalid/i.test(msg) ? 'disabled' : 'limited'; const resetAt = status === 'limited' ? Date.now() + 60_000 : 0; markTokenFail(provider, keyObj, status, resetAt, msg); } else if (!isNetworkErr) { // ChatGPT 404:旧版 Worker 缺 /v1/chat/completions 端点 if (provider === 'chatgpt' && /404/i.test(msg)) { throw new Error(`ChatGPT 后端未就绪:${msg}。Worker 缺 /v1/chat/completions 端点,请切到 🪙 CHATSAM 免Key 或 🧠 智谱 GLM,或提供 CLOUDFLARE_API_TOKEN 重部署 Worker`); } // 非网络类错误,也非限流/鉴权,直接抛出给用户看(比如内容审核/参数错误) throw err; } // 网络错误或可重试:最后一次尝试才抛,否则内部尝试下一个 key if (attempt < ks.length - 1) { const errShort = status ? `(${status})` : '(网络)'; toast(`「${keyObj.name}」${useModel} 重试中${errShort}...`); } } } // ChatGPT:/v1/chat/completions 404 → 换成友好提示 if (provider === 'chatgpt' && lastErr && /404/i.test(String(lastErr.message || lastErr))) { throw new Error('ChatGPT 后端 Worker(jc-skill-gpt)未包含 /v1/chat/completions 端点,返回 404。请切到 🪙 CHATSAM 免Key(推荐)或 🧠 智谱 GLM 立即使用;如需启用 ChatGPT 请提供 CLOUDFLARE_API_TOKEN 重新部署。'); } throw lastErr || new Error('所有账号都失败了'); } function markTokenOk(provider, keyObj) { const k = settings.keys.find((x) => x.provider === provider && x.key === keyObj.key && x.name === keyObj.name); if (!k) return; k.status = 'ok'; k.resetAt = 0; k.failCount = 0; k.lastUsedAt = Date.now(); k.lastError = ''; k.successCount = (k.successCount || 0) + 1; saveSettings(); renderKeyList(); } function markTokenFail(provider, keyObj, status, resetAt, errMsg) { const k = settings.keys.find((x) => x.provider === provider && x.key === keyObj.key && x.name === keyObj.name); if (!k) return; k.status = status; k.resetAt = resetAt; k.failCount = (k.failCount || 0) + 1; k.lastUsedAt = Date.now(); k.lastError = (errMsg || '').slice(0, 80); saveSettings(); renderKeyList(); } // ---------- 调用 OpenAI 兼容接口(流式) ---------- async function streamChat(p, apiKey, messages, onDelta, modelOverride, provider) { // 智谱:真实 API 不认 "auto" / gpt-4o 等模型,必须选一个智谱真实存在的模型(glm-4-flash/air/plus) // 兜底时防递归:第2次调用不要再兜底 const _fallbackDepth = (p && p._d) || 0; let model = modelOverride || (p && p.defaultModel) || 'gpt-4o'; if (provider === 'zhipu') { // 不是智谱3个真实模型就选推荐的(余额充足的) if (!/^glm-4-(flash|air|plus)$/i.test(model)) { model = getRecommendedZhipuModel(); // 真实模型:glm-4-flash / air / plus } } // 根据提供商选择端点 let base, headers, endpoint; if (provider === 'zhipu') { // 智谱 AI 直连 base = 'https://open.bigmodel.cn/api/paas/v4'; headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; endpoint = '/chat/completions'; // 智谱端点 } else if (provider === 'chatsam') { // CHATSAM:chatgpt2api 后端(号池内置),使用 chatSamApiKey 做 Authorization Bearer;失败才走智谱兜底 base = PROVIDERS.chatsam.baseUrl; const authKey = (apiKey && apiKey !== 'use-pool' && apiKey !== PROVIDERS.chatsam.fallbackAuthKey) ? apiKey : (PROVIDERS.chatsam.chatSamApiKey || PROVIDERS.chatsam.fallbackAuthKey); headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authKey}` }; endpoint = '/v1/chat/completions'; } else { // ChatGPT:走本地中继或 Worker base = apiBase(); headers = { 'Content-Type': 'application/json', 'X-Account-Token': apiKey }; if (!RELAY_OK) headers['Authorization'] = `Bearer ${DEPLOY_KEY}`; endpoint = '/v1/chat/completions'; } // AbortController 超时:45秒没完成就算了,避免页面假死 const ctrl = new AbortController(); const timeoutId = setTimeout(() => ctrl.abort(new Error('请求超时 (45s),请稍后重试')), 45_000); let lastUsage = null; let okHeaderChecked = false; try { const res = await fetch(base + endpoint, { method: 'POST', headers, signal: ctrl.signal, body: JSON.stringify({ model, messages, stream: true, temperature: provider === 'zhipu' ? 0.7 : 0.85 }) }); clearTimeout(timeoutId); okHeaderChecked = true; if (!res.ok) { let detail = ''; try { const jsonErr = (await res.json())?.error; detail = jsonErr?.message || jsonErr || ''; } catch {} // CHATSAM:任何非2xx 都降级智谱兜底(深度≥1时不再兜底,防递归) if (provider === 'chatsam' && _fallbackDepth < 1) { toast(`CHATSAM 暂不可用(${res.status}),自动切换到智谱兜底`, 3000); const fbk = PROVIDERS.zhipu; fbk._d = 1; return await streamChat(fbk, PROVIDERS.chatsam.fallbackAuthKey || BUILTIN_KEYS[0]?.key, messages, onDelta, getRecommendedZhipuModel(), 'zhipu'); } throw new Error(`${res.status} ${res.statusText}${detail ? ':' + detail : ''}`); } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let full = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data:')) continue; const data = trimmed.slice(5).trim(); if (data === '[DONE]') { if (provider === 'zhipu' && lastUsage) { updateModelTokens(model, lastUsage.total_tokens || 0); } return full; } try { const json = JSON.parse(data); const delta = json.choices?.[0]?.delta?.content || ''; if (delta) { full += delta; onDelta(delta); } if (json.usage) lastUsage = json.usage; } catch { /* 忽略解析错误的中间分片 */ } } } if (provider === 'zhipu' && lastUsage) { updateModelTokens(model, lastUsage.total_tokens || 0); } return full; } catch (err) { clearTimeout(timeoutId); // CHATSAM 任何失败都透明切智谱兜底(深度≥1时不再兜底防递归) const msg = String(err.message || err); if (provider === 'chatsam' && _fallbackDepth < 1) { toast(`CHATSAM 请求失败(${msg.slice(0, 30)}),自动切智谱`, 3500); const fbk = PROVIDERS.zhipu; fbk._d = 1; return await streamChat(fbk, PROVIDERS.chatsam.fallbackAuthKey || BUILTIN_KEYS[0]?.key, messages, onDelta, getRecommendedZhipuModel(), 'zhipu'); } throw err; } } // ---------- Service Worker ---------- if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('./sw.js').catch(() => {}); }); } // ---------- 加载 Skill(ROLE.md 使用指南 + 原作者 SKILL.md 角色素材)---------- let SKILL_PROMPT = ''; let SKILL_READY = false; // 去掉 Markdown 开头的 YAML frontmatter(--- ... --- 块) function stripFrontmatter(text) { return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(); } async function fetchMd(path) { // 加时间戳绕过 HuggingFace CDN 缓存,确保加载最新 SKILL.md const url = path + (path.includes('?') ? '&' : '?') + 'v=' + Date.now(); const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(path + ' HTTP ' + res.status); return stripFrontmatter(await res.text()); } async function loadSkill() { try { // ROLE.md(用户自定义使用指南)在前,SKILL.md(原作者角色素材)在后 const [role, skill] = await Promise.all([ fetchMd('./ROLE.md').catch(() => ''), fetchMd('./SKILL.md').catch(() => '') ]); const parts = []; if (role) parts.push(role); if (skill) parts.push('---\n\n# 角色素材补充\n\n' + skill); SKILL_PROMPT = parts.join('\n\n') || '你是一个有用的助手。'; SKILL_READY = true; } catch (e) { console.warn('Skill 加载失败:', e); SKILL_PROMPT = '你是一个有用的助手。'; SKILL_READY = true; } } // ---------- 启动 ---------- checkRelay().then(() => { if (RELAY_OK) toast('本地中继已连接 (localhost:8765)', 3000); }); setInterval(checkRelay, 15000); // 每 15 秒检测一次中继状态 loadSkill(); cleanStaleGirls(); // 启动时自动清理超过 5 个月没互动的女生档案 applySettingsToUI(); renderHistory(); renderGirlsPanel(); // 启动提示:免 key 平台直接可用,其他需配置 const _p = PROVIDERS[settings.provider]; if (_p && _p.noKeyRequired) { setTimeout(() => toast('✅ CHATSAM 已就绪,直接发送即可'), 600); } else if (keysFor(settings.provider).length === 0) { setTimeout(() => toast('点右上角设置,填 API Key 开始用'), 600); }