|
|
|
|
|
|
|
|
|
|
|
|
| const BUILTIN_KEYS = [
|
| { name: '🎁 我的智谱', provider: 'zhipu', key: 'e923eafcbf134ac3b8249f8e1b7b8d40.6gIuxGd27Aq4fRPc', model: 'auto', _builtin: true }
|
| ];
|
|
|
| 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;
|
|
|
| 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; }
|
|
|
| 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;
|
| }
|
|
|
|
|
| function apiBase() { return RELAY_OK ? RELAY_URL : WORKER_URL; }
|
|
|
| const PROVIDERS = {
|
| chatgpt: {
|
| name: 'ChatGPT 官网多账号池',
|
| defaultModel: 'gpt-4o',
|
|
|
| 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',
|
| 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,
|
|
|
| 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'];
|
|
|
|
|
| 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));
|
| }
|
|
|
|
|
| function getModelRemainingTokens(model) {
|
| const tokens = loadZhipuTokens();
|
| if (tokens[model] !== undefined) return tokens[model];
|
| return ZHIPU_MODEL_QUOTAS[model]?.total || 0;
|
| }
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| 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;
|
| }
|
|
|
| return MODEL_ROTATION_ORDER[0];
|
| }
|
|
|
|
|
| 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();
|
| let girls = loadGirls();
|
| let isStreaming = false;
|
| let keyRotationIdx = {};
|
| let pendingImage = null;
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
| const STALE_MS = 5 * 30 * 24 * 60 * 60 * 1000;
|
| 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 个月未互动的女生档案`);
|
| }
|
| }
|
|
|
|
|
|
|
| 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 });
|
| }
|
|
|
| for (const bk of BUILTIN_KEYS) {
|
| if (!keys.some((k) => k.provider === bk.provider && k.key === bk.key)) {
|
| keys.push({ ...bk, status: 'ok' });
|
| } else {
|
|
|
| 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));
|
| }
|
|
|
|
|
|
|
| function keysFor(provider) {
|
| const now = Date.now();
|
| return settings.keys
|
| .filter((k) => k.provider === provider)
|
| .map((k) => {
|
|
|
| if (k.status === 'limited' && k.resetAt && k.resetAt <= now) {
|
| k.status = 'ok';
|
| k.resetAt = 0;
|
| }
|
| return k;
|
| });
|
| }
|
|
|
| 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() {
|
|
|
| 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 {}
|
| }
|
| }
|
|
|
|
|
| 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();
|
|
|
| if (providerSel && providerSel.value !== p) providerSel.value = p;
|
| updateProviderUI();
|
| renderKeyList();
|
| renderDash();
|
| const name = quickProvSwitch.options[quickProvSwitch.selectedIndex]?.textContent?.split(' ')[1] || p;
|
| toast(`已切换:${name}`);
|
| });
|
| }
|
|
|
|
|
| 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 = '';
|
|
|
|
|
| const baseUrlInput = $('baseUrl');
|
| const baseUrlField = $('baseUrlField');
|
| if (p.requiresBaseUrl) {
|
| baseUrlField.style.display = '';
|
| baseUrlInput.value = settings.baseUrl || '';
|
| } else {
|
| baseUrlField.style.display = 'none';
|
| }
|
|
|
|
|
| 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 = '';
|
|
|
| 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 = '多账号轮询,单账号限流自动切换。';
|
| }
|
| }
|
|
|
|
|
| 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) => `<option value="${m}">${m}</option>`).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('国内')
|
| ? `<span class="tag tag-ok">${g.access}</span>`
|
| : `<span class="tag tag-warn">${g.access}</span>`;
|
| const freeTag = p.free ? `<span class="tag tag-free">免费</span>` : '';
|
| const steps = g.steps.map((s, i) => `<li><span class="step-num">${i + 1}</span><span>${s}</span></li>`).join('');
|
| providerGuideEl.innerHTML = `
|
| <div class="guide-head">
|
| <span class="guide-title">${p.name} · API 获取说明</span>
|
| ${freeTag}${accessTag}
|
| </div>
|
| <ul class="guide-steps">${steps}</ul>
|
| <div class="guide-meta">
|
| <div><span class="meta-label">价格</span><span class="meta-val">${g.price}</span></div>
|
| <div><span class="meta-label">充值</span><span class="meta-val">${g.pay}</span></div>
|
| <div><span class="meta-label">Key 格式</span><span class="meta-val mono">${g.keyFormat}</span></div>
|
| </div>
|
| `;
|
| }
|
|
|
|
|
| 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 `
|
| <div class="quota-item">
|
| <div class="quota-header">
|
| <span class="quota-model">${ZHIPU_MODEL_QUOTAS[model]?.name || model}</span>
|
| ${recommended ? '<span class="tag tag-ok">推荐</span>' : ''}
|
| </div>
|
| <div class="quota-bar-bg">
|
| <div class="quota-bar ${percentageClass}" style="width: ${percentage}%"></div>
|
| </div>
|
| <div class="quota-info">
|
| <span>剩余 ${formatTokens(remaining)} / ${formatTokens(total)}</span>
|
| <span>${percentage.toFixed(1)}%</span>
|
| </div>
|
| </div>
|
| `;
|
| }).join('');
|
|
|
| quotaEl.innerHTML = `
|
| <div class="quota-panel">
|
| <div class="quota-title">模型余额监控</div>
|
| <p class="quota-hint">自动轮调:优先使用余额充足的模型,低于 10% 自动切换</p>
|
| ${models}
|
| </div>
|
| `;
|
| }
|
|
|
|
|
| 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;
|
|
|
| if (p && p.noKeyRequired) {
|
| keyListEl.innerHTML = '<div class="empty-tip">✅ CHATSAM 内置号池已就绪,无需添加账号</div>';
|
| renderDash();
|
| return;
|
| }
|
|
|
|
|
| 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)
|
| ? `<div class="empty-tip">${emptyTip}</div>`
|
| : ks.map((k, i) => {
|
| const status = k.status || 'ok';
|
| const statusLabel = status === 'ok' ? '<span class="tag tag-ok">正常</span>'
|
| : status === 'limited' ? `<span class="tag tag-warn">限流${k.resetAt ? ' · ' + formatCountdown(k.resetAt) : ''}</span>`
|
| : '<span class="tag tag-bad">失效</span>';
|
| const errorTip = k.lastError ? ` title="${escapeHtml(k.lastError)}"` : '';
|
|
|
|
|
| let avail;
|
| if (settings.provider === 'zhipu') {
|
| avail = modelOptions;
|
| } else {
|
| avail = Array.isArray(k.probedModels) && k.probedModels.length ? k.probedModels : modelOptions;
|
| }
|
|
|
| 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');
|
|
|
| const modelSelect = avail.length
|
| ? `<select class="key-model-sel" data-idx="${i}" title="选择该账号使用的模型">
|
| ${avail.map((m) => `<option value="${m}"${m === useModel ? ' selected' : ''}>${m}</option>`).join('')}
|
| </select>`
|
| : '';
|
| const plan = k.sessionInfo && k.sessionInfo.planType ? `<span class="tag tag-plan">${k.sessionInfo.planType}</span>` : '';
|
| const tabBadge = '';
|
| const tabBtn = '';
|
|
|
| const probeBtn = isPool ? `<button class="key-probe" data-idx="${i}" title="探测此 token 能跑哪些模型">🔍</button>` : '';
|
| const sessionBtn = isPool ? `<button class="key-session" data-idx="${i}" title="查看账号配额和 plan">📊</button>` : '';
|
| return `
|
| <div class="key-item${status !== 'ok' ? ' key-disabled' : ''}" data-idx="${i}"${errorTip}>
|
| <span class="key-name">${escapeHtml(k.name || '未命名')}</span>
|
| <span class="key-val">${escapeHtml(maskKey(k.key))}</span>
|
| ${modelSelect}
|
| ${plan}
|
| ${statusLabel}
|
| ${probeBtn}
|
| ${sessionBtn}
|
| ${status === 'limited' ? `<button class="key-reset" data-idx="${i}" title="强制重置为正常">↻</button>` : ''}
|
| <button class="key-del" data-idx="${i}" aria-label="删除">
|
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m5 0V4a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v2"/></svg>
|
| </button>
|
| </div>
|
| `;
|
| }).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();
|
| }
|
|
|
|
|
| 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);
|
|
|
|
|
| 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);
|
|
|
| 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) {
|
|
|
|
|
| keyObj.failCount = (keyObj.failCount || 0) + 1;
|
| const lastErr = (results[0] && results[0].error) || '模型验证未通过';
|
| keyObj.lastError = lastErr;
|
|
|
| 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;
|
| }
|
|
|
|
|
| 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}`);
|
|
|
|
|
| const firstOkModel = data.firstOk && (data.firstOk.realModel || data.firstOk.model);
|
|
|
| const okList = (data.okModels || []).filter((m) => models.includes(m));
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
| }
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
| 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 = '<tr><td colspan="8" class="empty-tip">还没添加账号</td></tr>';
|
| return;
|
| }
|
| dashBody.innerHTML = ks.map((k, i) => {
|
| const status = k.status || 'ok';
|
| const statusCell = status === 'ok'
|
| ? '<span class="tag tag-ok">正常</span>'
|
| : status === 'limited'
|
| ? '<span class="tag tag-warn">限流</span>'
|
| : '<span class="tag tag-bad">失效</span>';
|
|
|
| const type = k.sessionInfo && k.sessionInfo.planType
|
| ? `<span class="tag tag-plan">${k.sessionInfo.planType}</span>`
|
| : '<span class="dim">未检测</span>';
|
|
|
| const acct = k.sessionInfo
|
| ? `${escapeHtml(k.sessionInfo.email || k.sessionInfo.name || '?')}<br><span class="dim small">${escapeHtml(k.name || '')}</span>`
|
| : `<span class="dim">未拉取</span>`;
|
|
|
| const ps = k.probeSummary;
|
| const quota = ps
|
| ? `可用 ${ps.tested === ps.totalModels ? '0' : (ps.totalModels - (ps.results.filter((r) => !r.ok).length))}/${ps.totalModels}<br><span class="dim small">当前:${escapeHtml(k.model || '?')}</span>`
|
| : (k.probedModels && k.probedModels.length
|
| ? `可用 ${k.probedModels.length}<br><span class="dim small">当前:${escapeHtml(k.model || '?')}</span>`
|
| : '<span class="dim">未探测</span>');
|
|
|
| const resetCell = k.resetAt && status === 'limited'
|
| ? `<span class="dim small">${formatCountdown(k.resetAt)}</span>`
|
| : (k.sessionInfo && k.sessionInfo.expires
|
| ? `token<br><span class="dim small">${k.sessionInfo.expires.slice(0, 10)}</span>`
|
| : '<span class="dim">—</span>');
|
| const ok = k.successCount || 0;
|
| const fail = k.failCount || 0;
|
| return `
|
| <tr class="${status !== 'ok' ? 'row-bad' : ''}">
|
| <td>${type}</td>
|
| <td>${statusCell}</td>
|
| <td>${acct}</td>
|
| <td>${quota}</td>
|
| <td>${resetCell}</td>
|
| <td class="num">${ok}</td>
|
| <td class="num">${fail}</td>
|
| <td class="ops">
|
| <button class="ghost-btn-mini dash-probe" data-idx="${i}" title="探测最强可用模型">🔍 探测</button>
|
| <button class="ghost-btn-mini dash-session" data-idx="${i}" title="拉取账号信息">📊 配额</button>
|
| <button class="ghost-btn-mini dash-reset" data-idx="${i}" title="重置状态为正常">↻ 重置</button>
|
| <button class="ghost-btn-mini dash-del" data-idx="${i}" title="删除账号">🗑</button>
|
| </td>
|
| </tr>
|
| `;
|
| }).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();
|
|
|
| if (settings.provider === 'chatgpt') {
|
| toast(`已添加「${name}」,自动探测可用模型...`);
|
| probeOneToken(newKey);
|
| } else if (settings.provider === 'zhipu') {
|
| toast(`✅ 已添加「${name}」,可直接发送`);
|
| } else {
|
| toast(`已添加「${name}」`);
|
| }
|
| });
|
|
|
|
|
| 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() {
|
|
|
| [...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();
|
| }
|
|
|
|
|
| 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);
|
| }
|
|
|
| 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) => {
|
|
|
| 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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>';
|
| 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 = '<p class="hint">还没有记录任何女生。发张聊天截图或贴段资料,会自动建档。</p>';
|
| 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 `
|
| <div class="girl-item" data-id="${g.id}">
|
| <span class="girl-dot" style="background:${g.color}"></span>
|
| <div class="girl-info">
|
| <div class="girl-name">${escapeHtml(g.name)} <span class="girl-emotion">${escapeHtml(g.emotion || '')}</span></div>
|
| <div class="girl-meta">最后互动:${last}</div>
|
| </div>
|
| <button class="girl-del" data-id="${g.id}" aria-label="删除档案">
|
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m5 0V4a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v2"/></svg>
|
| </button>
|
| </div>
|
| `;
|
| }).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标注完这一行后再空一行,接着按正常输出格式回复。`;
|
| }
|
|
|
|
|
| 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];
|
|
|
| const noKeyNeeded = p && p.noKeyRequired;
|
| if (!noKeyNeeded && keysFor(settings.provider).length === 0) {
|
| toast('先点右上角设置,填 API Key');
|
| settingsModal.hidden = false;
|
| return;
|
| }
|
|
|
|
|
| if (image) {
|
|
|
| if (noKeyNeeded) {
|
|
|
| } 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;
|
|
|
|
|
| if (!SKILL_READY) { await loadSkill(); }
|
|
|
|
|
| const sysContent = (SKILL_PROMPT || '你是一个有用的助手。') + buildGirlsContext();
|
| const msgs = [{ role: 'system', content: sysContent }];
|
| for (const m of history) {
|
| const role = m.role === 'bot' ? 'assistant' : 'user';
|
|
|
| if (m.image && m === history[history.length - 1]) {
|
|
|
| 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;
|
| }
|
| }
|
|
|
|
|
|
|
| async function streamChatWithRetry(messages, onDelta) {
|
| const provider = settings.provider;
|
| const p = PROVIDERS[provider];
|
| if (!p) throw new Error('未知的模型服务商');
|
|
|
|
|
| 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';
|
|
|
|
|
| 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);
|
|
|
|
|
| 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;
|
| 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) {
|
|
|
| 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;
|
| }
|
|
|
| if (attempt < ks.length - 1) {
|
| const errShort = status ? `(${status})` : '(网络)';
|
| toast(`「${keyObj.name}」${useModel} 重试中${errShort}...`);
|
| }
|
| }
|
| }
|
|
|
| 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();
|
| }
|
|
|
|
|
| async function streamChat(p, apiKey, messages, onDelta, modelOverride, provider) {
|
|
|
|
|
| const _fallbackDepth = (p && p._d) || 0;
|
| let model = modelOverride || (p && p.defaultModel) || 'gpt-4o';
|
| if (provider === 'zhipu') {
|
|
|
| if (!/^glm-4-(flash|air|plus)$/i.test(model)) {
|
| model = getRecommendedZhipuModel();
|
| }
|
| }
|
|
|
|
|
| let base, headers, endpoint;
|
| if (provider === 'zhipu') {
|
|
|
| base = 'https://open.bigmodel.cn/api/paas/v4';
|
| headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
|
| endpoint = '/chat/completions';
|
| } else if (provider === 'chatsam') {
|
|
|
| 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 {
|
|
|
| base = apiBase();
|
| headers = { 'Content-Type': 'application/json', 'X-Account-Token': apiKey };
|
| if (!RELAY_OK) headers['Authorization'] = `Bearer ${DEPLOY_KEY}`;
|
| endpoint = '/v1/chat/completions';
|
| }
|
|
|
|
|
| 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 {}
|
|
|
| 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);
|
|
|
| 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;
|
| }
|
| }
|
|
|
|
|
| if ('serviceWorker' in navigator) {
|
| window.addEventListener('load', () => {
|
| navigator.serviceWorker.register('./sw.js').catch(() => {});
|
| });
|
| }
|
|
|
|
|
| let SKILL_PROMPT = '';
|
| let SKILL_READY = false;
|
|
|
|
|
| function stripFrontmatter(text) {
|
| return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim();
|
| }
|
|
|
| async function fetchMd(path) {
|
|
|
| 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 {
|
|
|
| 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);
|
| loadSkill();
|
| cleanStaleGirls();
|
| applySettingsToUI();
|
| renderHistory();
|
| renderGirlsPanel();
|
|
|
| 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);
|
| }
|
|
|