diff --git a/persona-studio/backend/routes/apikey.js b/persona-studio/backend/routes/apikey.js new file mode 100644 index 00000000..d37aa650 --- /dev/null +++ b/persona-studio/backend/routes/apikey.js @@ -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; diff --git a/persona-studio/backend/server.js b/persona-studio/backend/server.js index 8a997b4e..03186e7f 100644 --- a/persona-studio/backend/server.js +++ b/persona-studio/backend/server.js @@ -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' ] }); diff --git a/persona-studio/frontend/chat.js b/persona-studio/frontend/chat.js index 4fcf88ef..dfa7d159 100644 --- a/persona-studio/frontend/chat.js +++ b/persona-studio/frontend/chat.js @@ -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'; } diff --git a/persona-studio/frontend/index.html b/persona-studio/frontend/index.html index 230df193..211d9e7d 100644 --- a/persona-studio/frontend/index.html +++ b/persona-studio/frontend/index.html @@ -14,6 +14,7 @@
+

请输入你的开发编号

编号由冰朔主控分配,格式:EXP-000 ~ EXP-011

@@ -33,8 +34,36 @@
- -

访客模式暂不可用,正式编号用户可正常登录

+ +
+

API Key 登录

+

输入你的第三方 API 信息,自动检测可用模型

+ + + + + + + + +
@@ -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'; + } diff --git a/persona-studio/frontend/style.css b/persona-studio/frontend/style.css index 274950d6..c9f3442f 100644 --- a/persona-studio/frontend/style.css +++ b/persona-studio/frontend/style.css @@ -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); } diff --git a/tests/smoke/apikey-detect.test.js b/tests/smoke/apikey-detect.test.js new file mode 100644 index 00000000..43b7420d --- /dev/null +++ b/tests/smoke/apikey-detect.test.js @@ -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); + }); +});