feat: upgrade Persona Studio login to support API Key auto-detect model mode

- Add backend route POST /api/ps/apikey/detect-models for model auto-detection with 1-hour caching
- Add backend route POST /api/ps/apikey/chat for proxying chat through user's own API credentials
- Update login page with API Key login section (API Base URL + API Key + model detection + model list)
- Update chat.js to support API Key mode with dynamic model usage (selected_model)
- Update styles for API Key login UI (detect status, model list, etc.)
- Add smoke tests for new endpoints and dev_id login backward compatibility
- Security: API Key stored only in sessionStorage, never persisted on backend

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-10 12:06:23 +00:00
parent 00d13e6537
commit a32c737073
6 changed files with 661 additions and 39 deletions

View File

@ -0,0 +1,265 @@
/**
* persona-studio · API Key 模型检测路由
* POST /api/ps/apikey/detect-models 检测可用模型
* POST /api/ps/apikey/chat 通过用户 API Key 对话
*/
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const https = require('https');
const http = require('http');
/* ---- 模型列表缓存1 小时有效期) ---- */
const modelCache = new Map();
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时
function getCacheKey(apiBase, apiKey) {
const hash = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
return apiBase + '::' + hash;
}
function getCachedModels(apiBase, apiKey) {
const key = getCacheKey(apiBase, apiKey);
const entry = modelCache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
return entry.models;
}
return null;
}
function setCachedModels(apiBase, apiKey, models) {
const key = getCacheKey(apiBase, apiKey);
modelCache.set(key, { models, timestamp: Date.now() });
}
/**
* 请求第三方 API /v1/models 接口
*/
function fetchModels(apiBase, apiKey, timeoutMs) {
return new Promise((resolve, reject) => {
// 规范化 apiBase去除末尾斜杠
const base = apiBase.replace(/\/+$/, '');
// 支持 base 已带 /v1 或不带的情况
const modelsPath = base.endsWith('/v1') ? base + '/models' : base + '/v1/models';
const url = new URL(modelsPath);
const isHttps = url.protocol === 'https:';
const mod = isHttps ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + (url.search || ''),
method: 'GET',
headers: {
'Authorization': 'Bearer ' + apiKey,
'Accept': 'application/json'
},
timeout: timeoutMs || 15000
};
const req = mod.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 401 || res.statusCode === 403) {
return reject(new Error('API Key 无效'));
}
if (res.statusCode >= 400) {
return reject(new Error('API Base 不可访问 (HTTP ' + res.statusCode + ')'));
}
try {
const json = JSON.parse(data);
const models = json.data || json.models || [];
const modelIds = models
.map(function (m) { return m.id || m.name || null; })
.filter(Boolean);
resolve(modelIds);
} catch (_e) {
reject(new Error('未检测到可用模型'));
}
});
});
req.on('error', () => {
reject(new Error('API Base 不可访问'));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('API Base 不可访问(请求超时)'));
});
req.end();
});
}
/**
* 调用用户 API chat/completions 接口
*/
function callUserApi({ apiBase, apiKey, model, messages, maxTokens, temperature, timeoutMs }) {
return new Promise((resolve, reject) => {
const base = apiBase.replace(/\/+$/, '');
const chatPath = base.endsWith('/v1') ? base + '/chat/completions' : base + '/v1/chat/completions';
const url = new URL(chatPath);
const isHttps = url.protocol === 'https:';
const mod = isHttps ? https : http;
const body = JSON.stringify({
model: model,
messages: messages,
max_tokens: maxTokens || 2000,
temperature: temperature != null ? temperature : 0.8
});
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey,
'Content-Length': Buffer.byteLength(body)
},
timeout: timeoutMs || 60000
};
const req = mod.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.choices && json.choices[0] && json.choices[0].message) {
resolve(json.choices[0].message.content);
} else if (json.error) {
reject(new Error(json.error.message || 'API 调用失败'));
} else {
reject(new Error('API 返回格式异常'));
}
} catch (_e) {
reject(new Error('API 返回解析失败'));
}
});
});
req.on('error', (err) => {
reject(new Error('API 请求失败: ' + err.message));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('API 请求超时'));
});
req.write(body);
req.end();
});
}
// POST /api/ps/apikey/detect-models
router.post('/detect-models', async (req, res) => {
const { api_base, api_key } = req.body || {};
if (!api_base || typeof api_base !== 'string') {
return res.status(400).json({
error: true,
code: 'MISSING_API_BASE',
message: '请输入 API Base URL'
});
}
if (!api_key || typeof api_key !== 'string') {
return res.status(400).json({
error: true,
code: 'MISSING_API_KEY',
message: '请输入 API Key'
});
}
// 检查缓存
const cached = getCachedModels(api_base, api_key);
if (cached) {
return res.json({
error: false,
models: cached,
count: cached.length,
cached: true
});
}
try {
const models = await fetchModels(api_base, api_key, 15000);
if (!models || models.length === 0) {
return res.status(404).json({
error: true,
code: 'NO_MODELS',
message: '未检测到可用模型'
});
}
// 写入缓存
setCachedModels(api_base, api_key, models);
res.json({
error: false,
models: models,
count: models.length,
cached: false
});
} catch (err) {
res.status(502).json({
error: true,
code: 'DETECT_FAILED',
message: err.message || '模型检测失败'
});
}
});
// POST /api/ps/apikey/chat
router.post('/chat', async (req, res) => {
const { api_base, api_key, model, messages } = req.body || {};
if (!api_base || !api_key || !model) {
return res.status(400).json({
error: true,
code: 'MISSING_PARAMS',
message: '缺少必要参数 (api_base, api_key, model)'
});
}
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: true,
code: 'MISSING_MESSAGES',
message: '缺少消息内容'
});
}
try {
const reply = await callUserApi({
apiBase: api_base,
apiKey: api_key,
model: model,
messages: messages,
maxTokens: 2000,
temperature: 0.8,
timeoutMs: 60000
});
res.json({
error: false,
reply: reply,
model: model
});
} catch (err) {
res.status(502).json({
error: true,
code: 'CHAT_FAILED',
message: err.message || '对话请求失败'
});
}
});
module.exports = router;

View File

@ -7,6 +7,7 @@ const authRoutes = require('./routes/auth');
const chatRoutes = require('./routes/chat');
const buildRoutes = require('./routes/build');
const notifyRoutes = require('./routes/notify');
const apikeyRoutes = require('./routes/apikey');
const app = express();
app.use(cors());
@ -20,6 +21,7 @@ app.use('/api/ps/auth', authRoutes);
app.use('/api/ps/chat', chatRoutes);
app.use('/api/ps/build', buildRoutes);
app.use('/api/ps/notify', notifyRoutes);
app.use('/api/ps/apikey', apikeyRoutes);
// ── 健康检查 ──
app.get('/api/ps/health', (_req, res) => {
@ -43,6 +45,8 @@ app.get('/', (_req, res) => {
'/api/ps/chat/history',
'/api/ps/build/start',
'/api/ps/notify/send',
'/api/ps/apikey/detect-models',
'/api/ps/apikey/chat',
'/api/ps/health'
]
});

View File

@ -4,6 +4,10 @@
const DEV_ID = sessionStorage.getItem('dev_id');
const SESSION_TOKEN = sessionStorage.getItem('session_token');
const LOGIN_MODE = sessionStorage.getItem('login_mode'); // 'apikey' or null
const USER_API_BASE = sessionStorage.getItem('user_api_base');
const USER_API_KEY = sessionStorage.getItem('user_api_key');
const SELECTED_MODEL = sessionStorage.getItem('selected_model');
const API_BASE = (function () {
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
@ -19,8 +23,25 @@ const API_BASE = (function () {
return;
}
document.getElementById('devIdDisplay').textContent = DEV_ID;
loadHistory();
// API Key 模式额外校验
if (LOGIN_MODE === 'apikey' && (!USER_API_BASE || !USER_API_KEY || !SELECTED_MODEL)) {
window.location.href = 'index.html';
return;
}
var displayId = DEV_ID;
if (LOGIN_MODE === 'apikey') {
displayId = SELECTED_MODEL;
}
document.getElementById('devIdDisplay').textContent = displayId;
if (LOGIN_MODE === 'apikey') {
// API Key 模式:显示欢迎信息,不加载历史
appendMessage('persona', '你好!当前使用模型:' + SELECTED_MODEL + '。有什么我可以帮你的?');
conversationHistory.push({ role: 'assistant', content: '你好!当前使用模型:' + SELECTED_MODEL + '。有什么我可以帮你的?' });
} else {
loadHistory();
}
})();
/* ---- State ---- */
@ -84,17 +105,40 @@ async function sendMessage() {
sendBtn.disabled = true;
try {
var res = await fetch(API_BASE + '/api/ps/chat/message', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
dev_id: DEV_ID,
message: text,
history: conversationHistory.slice(-20)
})
});
var data;
var data = await res.json();
if (LOGIN_MODE === 'apikey') {
// API Key 模式:通过后端代理调用用户的 API
var apiMessages = conversationHistory.slice(-20).map(function (msg) {
return { role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content };
});
var res = await fetch(API_BASE + '/api/ps/apikey/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_base: USER_API_BASE,
api_key: USER_API_KEY,
model: SELECTED_MODEL,
messages: apiMessages
})
});
data = await res.json();
} else {
// 开发编号模式:使用原有后端接口
var res = await fetch(API_BASE + '/api/ps/chat/message', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
dev_id: DEV_ID,
message: text,
history: conversationHistory.slice(-20)
})
});
data = await res.json();
}
if (data.reply) {
appendMessage('persona', data.reply);
@ -173,6 +217,10 @@ async function confirmBuild() {
function handleLogout() {
sessionStorage.removeItem('dev_id');
sessionStorage.removeItem('session_token');
sessionStorage.removeItem('login_mode');
sessionStorage.removeItem('user_api_base');
sessionStorage.removeItem('user_api_key');
sessionStorage.removeItem('selected_model');
window.location.href = 'index.html';
}

View File

@ -14,6 +14,7 @@
</div>
<div class="login-box">
<!-- ── 开发编号登录 ── -->
<h2>请输入你的开发编号</h2>
<p class="hint">编号由冰朔主控分配格式EXP-000 ~ EXP-011</p>
@ -33,8 +34,36 @@
<span></span>
</div>
<button id="guestBtn" class="btn-guest" onclick="handleGuestLogin()" disabled>🌊 访客体验模式(暂未开放)</button>
<p class="guest-hint">访客模式暂不可用,正式编号用户可正常登录</p>
<!-- ── API Key 登录 ── -->
<div class="apikey-section">
<h2>API Key 登录</h2>
<p class="hint">输入你的第三方 API 信息,自动检测可用模型</p>
<input
type="text"
id="apiBaseInput"
class="apikey-input"
placeholder="API Base URL如 https://api.openai.com"
autocomplete="off"
/>
<input
type="password"
id="apiKeyInput"
class="apikey-input"
placeholder="请输入你的 API Key"
autocomplete="off"
/>
<button type="button" id="detectBtn" class="btn-detect" onclick="handleDetectModels()">
🔍 检测可用模型
</button>
<div id="detectStatus" class="detect-status" style="display:none;"></div>
<div id="modelListContainer" class="model-list-container" style="display:none;">
<p class="model-list-title">请选择一个模型进入对话</p>
<div id="modelList" class="model-list"></div>
</div>
</div>
<div id="errorMsg" class="error-msg" style="display:none;"></div>
</div>
@ -55,11 +84,12 @@
return 'https://guanghulab.com';
}
/* ---- 开发编号登录 ---- */
async function handleLogin(e) {
e.preventDefault();
const devId = document.getElementById('devIdInput').value.trim().toUpperCase();
const errorEl = document.getElementById('errorMsg');
const btn = document.getElementById('loginBtn');
var devId = document.getElementById('devIdInput').value.trim().toUpperCase();
var errorEl = document.getElementById('errorMsg');
var btn = document.getElementById('loginBtn');
errorEl.style.display = 'none';
@ -73,13 +103,13 @@
btn.textContent = '验证中…';
try {
const res = await fetch(API_BASE + '/api/ps/auth/login', {
var res = await fetch(API_BASE + '/api/ps/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dev_id: devId })
});
const data = await res.json();
var data = await res.json();
if (!res.ok || data.error) {
errorEl.textContent = data.message || '登录失败,请检查编号';
@ -91,8 +121,12 @@
sessionStorage.setItem('dev_id', devId);
sessionStorage.setItem('session_token', data.token || '');
sessionStorage.removeItem('login_mode');
sessionStorage.removeItem('user_api_base');
sessionStorage.removeItem('user_api_key');
sessionStorage.removeItem('selected_model');
window.location.href = 'chat.html';
} catch (err) {
} catch (_err) {
errorEl.textContent = '服务暂时不可用,请稍后再试';
errorEl.style.display = 'block';
btn.disabled = false;
@ -102,40 +136,102 @@
return false;
}
async function handleGuestLogin() {
const errorEl = document.getElementById('errorMsg');
const btn = document.getElementById('guestBtn');
/* ---- API Key 模型检测 ---- */
async function handleDetectModels() {
var apiBase = document.getElementById('apiBaseInput').value.trim();
var apiKey = document.getElementById('apiKeyInput').value.trim();
var errorEl = document.getElementById('errorMsg');
var statusEl = document.getElementById('detectStatus');
var modelContainer = document.getElementById('modelListContainer');
var btn = document.getElementById('detectBtn');
errorEl.style.display = 'none';
modelContainer.style.display = 'none';
if (!apiBase) {
errorEl.textContent = '请输入 API Base URL';
errorEl.style.display = 'block';
return;
}
if (!apiKey) {
errorEl.textContent = '请输入 API Key';
errorEl.style.display = 'block';
return;
}
btn.disabled = true;
btn.textContent = '进入中…';
btn.textContent = '正在检测可用模型…';
statusEl.textContent = '正在检测可用模型...';
statusEl.className = 'detect-status detect-loading';
statusEl.style.display = 'block';
try {
const res = await fetch(API_BASE + '/api/ps/auth/login', {
var res = await fetch(API_BASE + '/api/ps/apikey/detect-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dev_id: 'GUEST' })
body: JSON.stringify({ api_base: apiBase, api_key: apiKey })
});
const data = await res.json();
var data = await res.json();
if (!res.ok || data.error) {
errorEl.textContent = data.message || '访客体验暂不可用';
errorEl.style.display = 'block';
statusEl.textContent = data.message || '未检测到模型,请检查 API Key';
statusEl.className = 'detect-status detect-error';
btn.disabled = false;
btn.textContent = '🌊 访客体验模式';
btn.textContent = '🔍 检测可用模型';
return;
}
sessionStorage.setItem('dev_id', 'GUEST');
sessionStorage.setItem('session_token', data.token || '');
window.location.href = 'chat.html';
} catch (err) {
errorEl.textContent = '服务暂时不可用,请稍后再试';
errorEl.style.display = 'block';
var models = data.models || [];
if (models.length === 0) {
statusEl.textContent = '未检测到可用模型';
statusEl.className = 'detect-status detect-error';
btn.disabled = false;
btn.textContent = '🔍 检测可用模型';
return;
}
statusEl.textContent = '检测到 ' + models.length + ' 个模型,请选择一个模型进入对话';
statusEl.className = 'detect-status detect-success';
renderModelList(models, apiBase, apiKey);
modelContainer.style.display = 'block';
btn.disabled = false;
btn.textContent = '🌊 访客体验模式';
btn.textContent = '🔍 重新检测';
} catch (_err) {
statusEl.textContent = '检测失败,请稍后重试';
statusEl.className = 'detect-status detect-error';
btn.disabled = false;
btn.textContent = '🔍 检测可用模型';
}
}
/* ---- 渲染模型列表 ---- */
function renderModelList(models, apiBase, apiKey) {
var listEl = document.getElementById('modelList');
listEl.innerHTML = '';
models.forEach(function (modelId) {
var item = document.createElement('button');
item.className = 'model-item';
item.textContent = modelId;
item.onclick = function () { enterApiKeyChat(apiBase, apiKey, modelId); };
listEl.appendChild(item);
});
}
/* ---- 选择模型 → 进入对话 ---- */
function enterApiKeyChat(apiBase, apiKey, selectedModel) {
sessionStorage.setItem('login_mode', 'apikey');
sessionStorage.setItem('user_api_base', apiBase);
sessionStorage.setItem('user_api_key', apiKey);
sessionStorage.setItem('selected_model', selectedModel);
sessionStorage.setItem('dev_id', 'APIKEY-USER');
sessionStorage.removeItem('session_token');
window.location.href = 'chat.html';
}
</script>
</body>
</html>

View File

@ -41,7 +41,7 @@ body {
flex-direction: column;
}
/* ---- Guest Mode ---- */
/* ---- Guest Mode / Divider ---- */
.guest-divider {
margin: 1.2rem 0;
text-align: center;
@ -90,6 +90,109 @@ body {
color: var(--text-secondary);
}
/* ---- API Key Login Section ---- */
.apikey-section {
text-align: center;
}
.apikey-section h2 {
font-size: 1.3rem;
margin-bottom: 0.5rem;
}
.apikey-input {
width: 100%;
padding: 0.8rem 1rem;
font-size: 0.95rem;
text-align: left;
border: 2px solid var(--border);
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
margin-bottom: 0.8rem;
font-family: inherit;
}
.apikey-input:focus {
border-color: var(--primary);
}
.btn-detect {
width: 100%;
padding: 0.8rem;
font-size: 1.05rem;
background: linear-gradient(135deg, var(--accent), #0969da);
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
}
.btn-detect:hover { opacity: 0.9; }
.btn-detect:disabled { opacity: 0.6; cursor: not-allowed; }
.detect-status {
margin-top: 0.8rem;
padding: 0.6rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
}
.detect-loading {
background: #eff6ff;
color: #2563eb;
}
.detect-success {
background: #f0fdf4;
color: #16a34a;
}
.detect-error {
background: #fef2f2;
color: #dc2626;
}
/* ---- Model List ---- */
.model-list-container {
margin-top: 1rem;
}
.model-list-title {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 0.6rem;
}
.model-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 240px;
overflow-y: auto;
}
.model-item {
width: 100%;
padding: 0.6rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
text-align: left;
transition: background 0.2s, border-color 0.2s;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.model-item:hover {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
/* ---- Login Page ---- */
.login-container {
justify-content: center;
@ -148,7 +251,8 @@ body {
margin-bottom: 1rem;
}
.login-box input[type="text"]:focus {
.login-box input[type="text"]:focus,
.login-box input[type="password"]:focus {
border-color: var(--primary);
}

View File

@ -0,0 +1,105 @@
/**
* Smoke test · API Key 模型检测接口
*
* 测试 POST /api/ps/apikey/detect-models POST /api/ps/apikey/chat
* 的输入验证与错误处理逻辑
*/
const http = require('http');
const BASE = process.env.TEST_BASE || 'http://localhost:3002';
function post(path, body) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const data = JSON.stringify(body);
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
},
timeout: 10000
};
const req = http.request(options, (res) => {
let chunks = '';
res.on('data', (c) => { chunks += c; });
res.on('end', () => {
try {
resolve({ status: res.statusCode, body: JSON.parse(chunks) });
} catch {
resolve({ status: res.statusCode, body: chunks });
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
describe('POST /api/ps/apikey/detect-models', () => {
test('returns MISSING_API_BASE when api_base is missing', async () => {
const res = await post('/api/ps/apikey/detect-models', {});
expect(res.status).toBe(400);
expect(res.body.error).toBe(true);
expect(res.body.code).toBe('MISSING_API_BASE');
});
test('returns MISSING_API_KEY when api_key is missing', async () => {
const res = await post('/api/ps/apikey/detect-models', { api_base: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toBe(true);
expect(res.body.code).toBe('MISSING_API_KEY');
});
test('returns error for unreachable API base', async () => {
const res = await post('/api/ps/apikey/detect-models', {
api_base: 'http://127.0.0.1:19999',
api_key: 'sk-test-invalid'
});
expect(res.status).toBe(502);
expect(res.body.error).toBe(true);
expect(res.body.code).toBe('DETECT_FAILED');
});
});
describe('POST /api/ps/apikey/chat', () => {
test('returns MISSING_PARAMS when required fields are missing', async () => {
const res = await post('/api/ps/apikey/chat', {});
expect(res.status).toBe(400);
expect(res.body.error).toBe(true);
expect(res.body.code).toBe('MISSING_PARAMS');
});
test('returns MISSING_MESSAGES when messages array is missing', async () => {
const res = await post('/api/ps/apikey/chat', {
api_base: 'https://example.com',
api_key: 'sk-test',
model: 'gpt-4'
});
expect(res.status).toBe(400);
expect(res.body.error).toBe(true);
expect(res.body.code).toBe('MISSING_MESSAGES');
});
});
describe('POST /api/ps/auth/login (dev_id still works)', () => {
test('EXP-000 dev_id login succeeds', async () => {
const res = await post('/api/ps/auth/login', { dev_id: 'EXP-000' });
expect(res.status).toBe(200);
expect(res.body.error).toBe(false);
expect(res.body.dev_id).toBe('EXP-000');
expect(res.body.token).toBeTruthy();
});
test('invalid dev_id is rejected', async () => {
const res = await post('/api/ps/auth/login', { dev_id: 'INVALID' });
expect(res.status).toBe(400);
expect(res.body.error).toBe(true);
});
});