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:
cc-dan
2026-03-11 06:14:42 +00:00
parent a9daf5ce4d
commit b2dbacb870
4 changed files with 267 additions and 80 deletions

View File

@@ -1,6 +1,9 @@
# 更新记录 # 更新记录
- **v1.2.6** - **v1.2.6**
- 新增编辑模板弹窗「获取上游模型列表」:通过 `/v1/models` 端点拉取可用模型,填充到四个模型输入框的下拉建议列表,支持自定义端点地址。
- 修改密码改为按钮+弹窗模式:设置面板中密码修改从内联表单改为独立弹窗,成功后自动关闭。
- 子弹窗关闭按钮样式适配:编辑模板和修改密码弹窗的关闭按钮统一为与主面板一致的风格。
- 新增 AskUserQuestion 选项预览区:左侧选项列表,右侧实时显示选项说明;桌面端 hover 切换,移动端 tap 选中后点确认按钮发送。 - 新增 AskUserQuestion 选项预览区:左侧选项列表,右侧实时显示选项说明;桌面端 hover 切换,移动端 tap 选中后点确认按钮发送。
- 修复 `~/.claude/settings.json` 写入竞争问题:改为原子写入(先写临时文件再 rename避免 Claude 子进程读到写了一半的文件导致随机 401 认证失败。 - 修复 `~/.claude/settings.json` 写入竞争问题:改为原子写入(先写临时文件再 rename避免 Claude 子进程读到写了一半的文件导致随机 401 认证失败。
- 修复 `ANTHROPIC_REASONING_MODEL` 被误删问题:补充到 settings.json 白名单,保留该字段不被覆盖。 - 修复 `ANTHROPIC_REASONING_MODEL` 被误删问题:补充到 settings.json 白名单,保留该字段不被覆盖。

View File

@@ -326,6 +326,10 @@
if (typeof _onModelConfig === 'function') _onModelConfig(msg.config); if (typeof _onModelConfig === 'function') _onModelConfig(msg.config);
break; break;
case 'fetch_models_result':
if (typeof _onFetchModelsResult === 'function') _onFetchModelsResult(msg);
break;
case 'background_done': case 'background_done':
// A background task completed (browser was disconnected or viewing another session) // A background task completed (browser was disconnected or viewing another session)
showToast(`${msg.title}」任务完成`, msg.sessionId); showToast(`${msg.title}」任务完成`, msg.sessionId);
@@ -1310,6 +1314,7 @@
let _onNotifyConfig = null; let _onNotifyConfig = null;
let _onNotifyTestResult = null; let _onNotifyTestResult = null;
let _onModelConfig = null; let _onModelConfig = null;
let _onFetchModelsResult = null;
const settingsBtn = $('#settings-btn'); const settingsBtn = $('#settings-btn');
@@ -1373,23 +1378,9 @@
<div class="settings-divider"></div> <div class="settings-divider"></div>
<div class="settings-section-title">修改密码</div> <div class="settings-section-title">修改密码</div>
<div class="settings-field"> <div class="settings-actions" style="margin-top:0">
<label>当前密码</label> <button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
<input type="password" id="settings-current-pw" placeholder="当前密码" autocomplete="current-password">
</div> </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); overlay.appendChild(panel);
@@ -1519,22 +1510,44 @@
<label>API Base URL</label> <label>API Base URL</label>
<input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}"> <input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
</div> </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"> <div class="settings-field">
<label>默认模型 (ANTHROPIC_MODEL)</label> <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>
<div class="settings-field"> <div class="settings-field">
<label>Opus 模型名</label> <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>
<div class="settings-field"> <div class="settings-field">
<label>Sonnet 模型名</label> <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>
<div class="settings-field"> <div class="settings-field">
<label>Haiku 模型名</label> <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> </div>
<datalist id="tpl-dl-models"></datalist>
<div class="settings-actions"> <div class="settings-actions">
<button class="btn-save" id="tpl-ed-ok">确定</button> <button class="btn-save" id="tpl-ed-ok">确定</button>
</div> </div>
@@ -1542,7 +1555,51 @@
modalOverlay.appendChild(modal); modalOverlay.appendChild(modal);
document.body.appendChild(modalOverlay); 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); modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal);
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); }); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
@@ -1694,69 +1751,108 @@
showStatus('已保存', 'success'); showStatus('已保存', 'success');
}); });
// Password change in settings // Password change button -> opens modal
const settingsCurrentPw = panel.querySelector('#settings-current-pw'); const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
const settingsNewPw = panel.querySelector('#settings-new-pw'); pwOpenModalBtn.addEventListener('click', openPasswordModal);
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');
function checkSettingsPw() { function openPasswordModal() {
const newPw = settingsNewPw.value; const pwOverlay = document.createElement('div');
const confirmPw = settingsConfirmPw.value; pwOverlay.className = 'settings-overlay';
const currentPw = settingsCurrentPw.value; pwOverlay.style.zIndex = '10001';
if (!newPw) { const pwModal = document.createElement('div');
pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; pwModal.className = 'settings-panel';
pwHint.className = 'password-hint'; pwModal.style.maxWidth = '400px';
pwChangeBtn.disabled = true; pwModal.innerHTML = `
return; <div class="settings-header">
} <h3>修改密码</h3>
const result = clientValidatePassword(newPw); <button class="settings-close" id="pw-modal-close">&times;</button>
if (!result.valid) { </div>
pwHint.textContent = result.message; <div class="settings-field">
pwHint.className = 'password-hint error'; <label>当前密码</label>
pwChangeBtn.disabled = true; <input type="password" id="pw-modal-current" placeholder="当前密码" autocomplete="current-password">
return; </div>
} <div class="settings-field">
pwHint.textContent = '密码强度符合要求'; <label>新密码</label>
pwHint.className = 'password-hint success'; <input type="password" id="pw-modal-new" placeholder="新密码" autocomplete="new-password">
pwChangeBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw; <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);
settingsCurrentPw.addEventListener('input', checkSettingsPw); const currentPwIn = pwModal.querySelector('#pw-modal-current');
settingsNewPw.addEventListener('input', checkSettingsPw); const newPwIn = pwModal.querySelector('#pw-modal-new');
settingsConfirmPw.addEventListener('input', checkSettingsPw); 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');
pwChangeBtn.addEventListener('click', () => { function checkPw() {
const currentPw = settingsCurrentPw.value; const newPw = newPwIn.value;
const newPw = settingsNewPw.value; const confirmPw = confirmPwIn.value;
const confirmPw = settingsConfirmPw.value; const currentPw = currentPwIn.value;
if (newPw !== confirmPw) { if (!newPw) {
pwStatus.textContent = '两次密码不一致'; hint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
pwStatus.className = 'settings-status error'; hint.className = 'password-hint';
return; submitBtn.disabled = true;
} return;
pwChangeBtn.disabled = true;
pwStatus.textContent = '正在修改...';
pwStatus.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';
} else {
pwStatus.textContent = result.message || '修改失败';
pwStatus.className = 'settings-status error';
pwChangeBtn.disabled = false;
} }
}; const result = clientValidatePassword(newPw);
send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw }); if (!result.valid) {
}); hint.textContent = result.message;
hint.className = 'password-hint error';
submitBtn.disabled = true;
return;
}
hint.textContent = '密码强度符合要求';
hint.className = 'password-hint success';
submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
}
currentPwIn.addEventListener('input', checkPw);
newPwIn.addEventListener('input', checkPw);
confirmPwIn.addEventListener('input', checkPw);
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) {
status.textContent = '两次密码不一致';
status.className = 'settings-status error';
return;
}
submitBtn.disabled = true;
status.textContent = '正在修改...';
status.className = 'settings-status';
_onPasswordChanged = (result) => {
if (result.success) {
status.textContent = result.message || '密码修改成功';
status.className = 'settings-status success';
setTimeout(closePwModal, 1200);
} else {
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); document.addEventListener('keydown', _settingsEscape);
} }
@@ -1767,6 +1863,7 @@
_onNotifyConfig = null; _onNotifyConfig = null;
_onNotifyTestResult = null; _onNotifyTestResult = null;
_onModelConfig = null; _onModelConfig = null;
_onFetchModelsResult = null;
document.removeEventListener('keydown', _settingsEscape); document.removeEventListener('keydown', _settingsEscape);
} }

View File

@@ -1078,6 +1078,27 @@ body {
border-radius: 6px; border-radius: 6px;
} }
.settings-panel h3 .settings-close:hover { color: var(--text-primary); background: var(--bg-tertiary); } .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 { .settings-field {
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@@ -849,6 +849,9 @@ wss.on('connection', (ws) => {
case 'save_model_config': case 'save_model_config':
handleSaveModelConfig(ws, msg.config); handleSaveModelConfig(ws, msg.config);
break; break;
case 'fetch_models':
handleFetchModels(ws, msg);
break;
default: default:
wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` }); wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` });
} }
@@ -973,6 +976,69 @@ function handleSaveModelConfig(ws, newConfig) {
wsSend(ws, { type: 'system_message', message: '模型配置已保存' }); 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 === // === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId) { function handleSlashCommand(ws, text, sessionId) {
const parts = text.split(/\s+/); const parts = text.split(/\s+/);