feat: fetch upstream model list, password modal, sub-modal close button styling
- Add fetch models from upstream API via /v1/models endpoint with datalist suggestions - Refactor password change from inline form to button + modal - Fix sub-modal close button styling to match main settings panel
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# 更新记录
|
||||
|
||||
- **v1.2.6**
|
||||
- 新增编辑模板弹窗「获取上游模型列表」:通过 `/v1/models` 端点拉取可用模型,填充到四个模型输入框的下拉建议列表,支持自定义端点地址。
|
||||
- 修改密码改为按钮+弹窗模式:设置面板中密码修改从内联表单改为独立弹窗,成功后自动关闭。
|
||||
- 子弹窗关闭按钮样式适配:编辑模板和修改密码弹窗的关闭按钮统一为与主面板一致的风格。
|
||||
- 新增 AskUserQuestion 选项预览区:左侧选项列表,右侧实时显示选项说明;桌面端 hover 切换,移动端 tap 选中后点确认按钮发送。
|
||||
- 修复 `~/.claude/settings.json` 写入竞争问题:改为原子写入(先写临时文件再 rename),避免 Claude 子进程读到写了一半的文件导致随机 401 认证失败。
|
||||
- 修复 `ANTHROPIC_REASONING_MODEL` 被误删问题:补充到 settings.json 白名单,保留该字段不被覆盖。
|
||||
|
||||
223
public/app.js
223
public/app.js
@@ -326,6 +326,10 @@
|
||||
if (typeof _onModelConfig === 'function') _onModelConfig(msg.config);
|
||||
break;
|
||||
|
||||
case 'fetch_models_result':
|
||||
if (typeof _onFetchModelsResult === 'function') _onFetchModelsResult(msg);
|
||||
break;
|
||||
|
||||
case 'background_done':
|
||||
// A background task completed (browser was disconnected or viewing another session)
|
||||
showToast(`「${msg.title}」任务完成`, msg.sessionId);
|
||||
@@ -1310,6 +1314,7 @@
|
||||
let _onNotifyConfig = null;
|
||||
let _onNotifyTestResult = null;
|
||||
let _onModelConfig = null;
|
||||
let _onFetchModelsResult = null;
|
||||
|
||||
const settingsBtn = $('#settings-btn');
|
||||
|
||||
@@ -1373,23 +1378,9 @@
|
||||
<div class="settings-divider"></div>
|
||||
|
||||
<div class="settings-section-title">修改密码</div>
|
||||
<div class="settings-field">
|
||||
<label>当前密码</label>
|
||||
<input type="password" id="settings-current-pw" placeholder="当前密码" autocomplete="current-password">
|
||||
<div class="settings-actions" style="margin-top:0">
|
||||
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>新密码</label>
|
||||
<input type="password" id="settings-new-pw" placeholder="新密码" autocomplete="new-password">
|
||||
<div class="password-hint" id="settings-pw-hint">至少 8 位,包含大写/小写/数字/特殊字符中的 2 种</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" id="settings-confirm-pw" placeholder="确认新密码" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="btn-save" id="pw-change-btn" disabled>修改密码</button>
|
||||
</div>
|
||||
<div class="settings-status" id="pw-status"></div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(panel);
|
||||
@@ -1519,22 +1510,44 @@
|
||||
<label>API Base URL</label>
|
||||
<input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
|
||||
</div>
|
||||
|
||||
<div class="settings-divider" style="margin:12px 0"></div>
|
||||
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:600">
|
||||
获取上游模型列表
|
||||
</label>
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-top:4px">
|
||||
<label style="font-size:0.85em;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||||
<input type="checkbox" id="tpl-ed-custom-endpoint"> 端点
|
||||
</label>
|
||||
<input type="text" id="tpl-ed-models-endpoint" placeholder="/v1/models" style="flex:1;display:none" value="">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;align-items:center">
|
||||
<button class="btn-test" id="tpl-ed-fetch-models" style="padding:4px 12px;white-space:nowrap">获取模型</button>
|
||||
<span id="tpl-ed-fetch-status" style="font-size:0.85em;color:var(--text-secondary)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-divider" style="margin:12px 0"></div>
|
||||
|
||||
<div class="settings-field">
|
||||
<label>默认模型 (ANTHROPIC_MODEL)</label>
|
||||
<input type="text" id="tpl-ed-default" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}">
|
||||
<input type="text" id="tpl-ed-default" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Opus 模型名</label>
|
||||
<input type="text" id="tpl-ed-opus" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}">
|
||||
<input type="text" id="tpl-ed-opus" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Sonnet 模型名</label>
|
||||
<input type="text" id="tpl-ed-sonnet" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}">
|
||||
<input type="text" id="tpl-ed-sonnet" list="tpl-dl-models" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Haiku 模型名</label>
|
||||
<input type="text" id="tpl-ed-haiku" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}">
|
||||
<input type="text" id="tpl-ed-haiku" list="tpl-dl-models" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}" autocomplete="off">
|
||||
</div>
|
||||
<datalist id="tpl-dl-models"></datalist>
|
||||
<div class="settings-actions">
|
||||
<button class="btn-save" id="tpl-ed-ok">确定</button>
|
||||
</div>
|
||||
@@ -1542,7 +1555,51 @@
|
||||
modalOverlay.appendChild(modal);
|
||||
document.body.appendChild(modalOverlay);
|
||||
|
||||
const closeModal = () => { document.body.removeChild(modalOverlay); };
|
||||
// Custom endpoint checkbox toggle
|
||||
const customEndpointCb = modal.querySelector('#tpl-ed-custom-endpoint');
|
||||
const endpointInput = modal.querySelector('#tpl-ed-models-endpoint');
|
||||
customEndpointCb.addEventListener('change', () => {
|
||||
endpointInput.style.display = customEndpointCb.checked ? '' : 'none';
|
||||
});
|
||||
|
||||
// Fetch models
|
||||
const fetchBtn = modal.querySelector('#tpl-ed-fetch-models');
|
||||
const fetchStatus = modal.querySelector('#tpl-ed-fetch-status');
|
||||
const datalist = modal.querySelector('#tpl-dl-models');
|
||||
|
||||
fetchBtn.addEventListener('click', () => {
|
||||
const apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
|
||||
const apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
|
||||
if (!apiBase || !apiKey) {
|
||||
fetchStatus.textContent = '请先填写 API Base 和 API Key';
|
||||
fetchStatus.style.color = 'var(--text-error, #e85d5d)';
|
||||
return;
|
||||
}
|
||||
const modelsEndpoint = customEndpointCb.checked ? endpointInput.value.trim() : '';
|
||||
fetchBtn.disabled = true;
|
||||
fetchStatus.textContent = '正在获取...';
|
||||
fetchStatus.style.color = 'var(--text-secondary)';
|
||||
|
||||
_onFetchModelsResult = (result) => {
|
||||
_onFetchModelsResult = null;
|
||||
fetchBtn.disabled = false;
|
||||
if (result.success) {
|
||||
datalist.innerHTML = result.models.map(m => `<option value="${escapeHtml(m)}">`).join('');
|
||||
fetchStatus.textContent = `获取到 ${result.models.length} 个模型`;
|
||||
fetchStatus.style.color = 'var(--text-success, #5dbe5d)';
|
||||
} else {
|
||||
fetchStatus.textContent = result.message || '获取失败';
|
||||
fetchStatus.style.color = 'var(--text-error, #e85d5d)';
|
||||
}
|
||||
};
|
||||
|
||||
send({ type: 'fetch_models', apiBase, apiKey, modelsEndpoint: modelsEndpoint || undefined, templateName: tpl.name });
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
_onFetchModelsResult = null;
|
||||
document.body.removeChild(modalOverlay);
|
||||
};
|
||||
modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal);
|
||||
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
|
||||
|
||||
@@ -1694,70 +1751,109 @@
|
||||
showStatus('已保存', 'success');
|
||||
});
|
||||
|
||||
// Password change in settings
|
||||
const settingsCurrentPw = panel.querySelector('#settings-current-pw');
|
||||
const settingsNewPw = panel.querySelector('#settings-new-pw');
|
||||
const settingsConfirmPw = panel.querySelector('#settings-confirm-pw');
|
||||
const pwHint = panel.querySelector('#settings-pw-hint');
|
||||
const pwChangeBtn = panel.querySelector('#pw-change-btn');
|
||||
const pwStatus = panel.querySelector('#pw-status');
|
||||
// Password change button -> opens modal
|
||||
const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
|
||||
pwOpenModalBtn.addEventListener('click', openPasswordModal);
|
||||
|
||||
function checkSettingsPw() {
|
||||
const newPw = settingsNewPw.value;
|
||||
const confirmPw = settingsConfirmPw.value;
|
||||
const currentPw = settingsCurrentPw.value;
|
||||
function openPasswordModal() {
|
||||
const pwOverlay = document.createElement('div');
|
||||
pwOverlay.className = 'settings-overlay';
|
||||
pwOverlay.style.zIndex = '10001';
|
||||
const pwModal = document.createElement('div');
|
||||
pwModal.className = 'settings-panel';
|
||||
pwModal.style.maxWidth = '400px';
|
||||
pwModal.innerHTML = `
|
||||
<div class="settings-header">
|
||||
<h3>修改密码</h3>
|
||||
<button class="settings-close" id="pw-modal-close">×</button>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>当前密码</label>
|
||||
<input type="password" id="pw-modal-current" placeholder="当前密码" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>新密码</label>
|
||||
<input type="password" id="pw-modal-new" placeholder="新密码" autocomplete="new-password">
|
||||
<div class="password-hint" id="pw-modal-hint">至少 8 位,包含大写/小写/数字/特殊字符中的 2 种</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" id="pw-modal-confirm" placeholder="确认新密码" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="btn-save" id="pw-modal-submit" disabled>修改密码</button>
|
||||
</div>
|
||||
<div class="settings-status" id="pw-modal-status"></div>
|
||||
`;
|
||||
pwOverlay.appendChild(pwModal);
|
||||
document.body.appendChild(pwOverlay);
|
||||
|
||||
const currentPwIn = pwModal.querySelector('#pw-modal-current');
|
||||
const newPwIn = pwModal.querySelector('#pw-modal-new');
|
||||
const confirmPwIn = pwModal.querySelector('#pw-modal-confirm');
|
||||
const hint = pwModal.querySelector('#pw-modal-hint');
|
||||
const submitBtn = pwModal.querySelector('#pw-modal-submit');
|
||||
const status = pwModal.querySelector('#pw-modal-status');
|
||||
|
||||
function checkPw() {
|
||||
const newPw = newPwIn.value;
|
||||
const confirmPw = confirmPwIn.value;
|
||||
const currentPw = currentPwIn.value;
|
||||
if (!newPw) {
|
||||
pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
|
||||
pwHint.className = 'password-hint';
|
||||
pwChangeBtn.disabled = true;
|
||||
hint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
|
||||
hint.className = 'password-hint';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
const result = clientValidatePassword(newPw);
|
||||
if (!result.valid) {
|
||||
pwHint.textContent = result.message;
|
||||
pwHint.className = 'password-hint error';
|
||||
pwChangeBtn.disabled = true;
|
||||
hint.textContent = result.message;
|
||||
hint.className = 'password-hint error';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
pwHint.textContent = '密码强度符合要求';
|
||||
pwHint.className = 'password-hint success';
|
||||
pwChangeBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
|
||||
hint.textContent = '密码强度符合要求';
|
||||
hint.className = 'password-hint success';
|
||||
submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
|
||||
}
|
||||
|
||||
settingsCurrentPw.addEventListener('input', checkSettingsPw);
|
||||
settingsNewPw.addEventListener('input', checkSettingsPw);
|
||||
settingsConfirmPw.addEventListener('input', checkSettingsPw);
|
||||
currentPwIn.addEventListener('input', checkPw);
|
||||
newPwIn.addEventListener('input', checkPw);
|
||||
confirmPwIn.addEventListener('input', checkPw);
|
||||
|
||||
pwChangeBtn.addEventListener('click', () => {
|
||||
const currentPw = settingsCurrentPw.value;
|
||||
const newPw = settingsNewPw.value;
|
||||
const confirmPw = settingsConfirmPw.value;
|
||||
const closePwModal = () => { document.body.removeChild(pwOverlay); };
|
||||
pwModal.querySelector('#pw-modal-close').addEventListener('click', closePwModal);
|
||||
pwOverlay.addEventListener('click', (e) => { if (e.target === pwOverlay) closePwModal(); });
|
||||
|
||||
submitBtn.addEventListener('click', () => {
|
||||
const currentPw = currentPwIn.value;
|
||||
const newPw = newPwIn.value;
|
||||
const confirmPw = confirmPwIn.value;
|
||||
if (newPw !== confirmPw) {
|
||||
pwStatus.textContent = '两次密码不一致';
|
||||
pwStatus.className = 'settings-status error';
|
||||
status.textContent = '两次密码不一致';
|
||||
status.className = 'settings-status error';
|
||||
return;
|
||||
}
|
||||
pwChangeBtn.disabled = true;
|
||||
pwStatus.textContent = '正在修改...';
|
||||
pwStatus.className = 'settings-status';
|
||||
submitBtn.disabled = true;
|
||||
status.textContent = '正在修改...';
|
||||
status.className = 'settings-status';
|
||||
_onPasswordChanged = (result) => {
|
||||
if (result.success) {
|
||||
pwStatus.textContent = result.message || '密码修改成功';
|
||||
pwStatus.className = 'settings-status success';
|
||||
settingsCurrentPw.value = '';
|
||||
settingsNewPw.value = '';
|
||||
settingsConfirmPw.value = '';
|
||||
pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
|
||||
pwHint.className = 'password-hint';
|
||||
status.textContent = result.message || '密码修改成功';
|
||||
status.className = 'settings-status success';
|
||||
setTimeout(closePwModal, 1200);
|
||||
} else {
|
||||
pwStatus.textContent = result.message || '修改失败';
|
||||
pwStatus.className = 'settings-status error';
|
||||
pwChangeBtn.disabled = false;
|
||||
status.textContent = result.message || '修改失败';
|
||||
status.className = 'settings-status error';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw });
|
||||
});
|
||||
|
||||
currentPwIn.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', _settingsEscape);
|
||||
}
|
||||
|
||||
@@ -1767,6 +1863,7 @@
|
||||
_onNotifyConfig = null;
|
||||
_onNotifyTestResult = null;
|
||||
_onModelConfig = null;
|
||||
_onFetchModelsResult = null;
|
||||
document.removeEventListener('keydown', _settingsEscape);
|
||||
}
|
||||
|
||||
|
||||
@@ -1078,6 +1078,27 @@ body {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.settings-panel h3 .settings-close:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.settings-header h3 {
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.settings-header .settings-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.settings-header .settings-close:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.settings-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
66
server.js
66
server.js
@@ -849,6 +849,9 @@ wss.on('connection', (ws) => {
|
||||
case 'save_model_config':
|
||||
handleSaveModelConfig(ws, msg.config);
|
||||
break;
|
||||
case 'fetch_models':
|
||||
handleFetchModels(ws, msg);
|
||||
break;
|
||||
default:
|
||||
wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` });
|
||||
}
|
||||
@@ -973,6 +976,69 @@ function handleSaveModelConfig(ws, newConfig) {
|
||||
wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
|
||||
}
|
||||
|
||||
// === Fetch Upstream Models ===
|
||||
function handleFetchModels(ws, msg) {
|
||||
const { apiBase, apiKey, modelsEndpoint } = msg;
|
||||
if (!apiBase || !apiKey) {
|
||||
return wsSend(ws, { type: 'fetch_models_result', success: false, message: '需要填写 API Base 和 API Key' });
|
||||
}
|
||||
// Build URL: apiBase + modelsEndpoint (default /v1/models)
|
||||
let base = apiBase.replace(/\/+$/, '');
|
||||
const endpoint = modelsEndpoint || '/v1/models';
|
||||
const fullUrl = base + endpoint;
|
||||
|
||||
let parsed;
|
||||
try { parsed = new URL(fullUrl); } catch {
|
||||
return wsSend(ws, { type: 'fetch_models_result', success: false, message: '无效的 URL: ' + fullUrl });
|
||||
}
|
||||
|
||||
// Resolve real apiKey (if masked, look up saved config by template name or apiBase)
|
||||
let realKey = apiKey;
|
||||
if (apiKey.includes('****')) {
|
||||
const config = loadModelConfig();
|
||||
const saved = (config.templates || []);
|
||||
// Match by template name first, then by apiBase
|
||||
const tpl = (msg.templateName && saved.find(t => t.name === msg.templateName))
|
||||
|| saved.find(t => t.apiBase && t.apiBase.replace(/\/+$/, '') === base)
|
||||
|| null;
|
||||
if (tpl && tpl.apiKey && !tpl.apiKey.includes('****')) realKey = tpl.apiKey;
|
||||
else return wsSend(ws, { type: 'fetch_models_result', success: false, message: 'API Key 已脱敏,请重新输入完整 Key' });
|
||||
}
|
||||
|
||||
const mod = parsed.protocol === 'https:' ? require('https') : require('http');
|
||||
const reqOptions = {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${realKey}` },
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
const req = mod.request(parsed, reqOptions, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return wsSend(ws, { type: 'fetch_models_result', success: false, message: `HTTP ${res.statusCode}: ${body.slice(0, 200)}` });
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
const models = (json.data || json.models || []).map(m => typeof m === 'string' ? m : m.id || m.name || '').filter(Boolean).sort();
|
||||
wsSend(ws, { type: 'fetch_models_result', success: true, models });
|
||||
} catch (e) {
|
||||
wsSend(ws, { type: 'fetch_models_result', success: false, message: '解析响应失败: ' + e.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
wsSend(ws, { type: 'fetch_models_result', success: false, message: '请求失败: ' + e.message });
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
wsSend(ws, { type: 'fetch_models_result', success: false, message: '请求超时 (15s)' });
|
||||
});
|
||||
req.end();
|
||||
}
|
||||
|
||||
// === Slash Command Handler ===
|
||||
function handleSlashCommand(ws, text, sessionId) {
|
||||
const parts = text.split(/\s+/);
|
||||
|
||||
Reference in New Issue
Block a user