// 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}
`;
}
// 渲染智谱模型余额显示面板
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 `
剩余 ${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);
}