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:
parent
00d13e6537
commit
a32c737073
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
]
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue