diff --git a/persona-studio/backend/routes/apikey.js b/persona-studio/backend/routes/apikey.js index ebfcb434..c88b186a 100644 --- a/persona-studio/backend/routes/apikey.js +++ b/persona-studio/backend/routes/apikey.js @@ -169,6 +169,19 @@ router.post('/detect-models', async (req, res) => { }); } + // 校验 URL 格式 + try { + const testBase = api_base.replace(/\/+$/, ''); + const testPath = testBase.endsWith('/v1') ? testBase + '/models' : testBase + '/v1/models'; + new URL(testPath); + } catch (_e) { + return res.status(400).json({ + error: true, + code: 'INVALID_API_BASE', + message: 'API Base URL 格式无效' + }); + } + if (!api_key || typeof api_key !== 'string') { return res.status(400).json({ error: true, @@ -216,10 +229,22 @@ router.post('/detect-models', async (req, res) => { cached: false }); } catch (err) { + const errMsg = err.message || '模型检测失败'; + let code = 'DETECT_FAILED'; + + // 区分 DNS / 网络 / 超时错误 + if (/ENOTFOUND|getaddrinfo/.test(errMsg)) { + code = 'DNS_ERROR'; + } else if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|socket hang up/.test(errMsg)) { + code = 'NETWORK_ERROR'; + } else if (/timeout|ETIMEDOUT|请求超时/.test(errMsg)) { + code = 'TIMEOUT'; + } + res.status(502).json({ error: true, - code: 'DETECT_FAILED', - message: err.message || '模型检测失败' + code: code, + message: errMsg }); } }); diff --git a/persona-studio/backend/server.js b/persona-studio/backend/server.js index 03186e7f..a4899137 100644 --- a/persona-studio/backend/server.js +++ b/persona-studio/backend/server.js @@ -33,6 +33,16 @@ app.get('/api/ps/health', (_req, res) => { }); }); +// 兼容 /api/health 路径 +app.get('/api/health', (_req, res) => { + res.json({ + status: 'ok', + service: 'persona-studio', + version: '1.0.0', + timestamp: new Date().toISOString() + }); +}); + // ── 根路由 ── app.get('/', (_req, res) => { res.json({ diff --git a/persona-studio/frontend/chat.js b/persona-studio/frontend/chat.js index c1c55e03..87029186 100644 --- a/persona-studio/frontend/chat.js +++ b/persona-studio/frontend/chat.js @@ -105,26 +105,9 @@ async function sendMessage() { sendBtn.disabled = true; try { - let data; - if (LOGIN_MODE === 'apikey') { - // API Key 模式:通过后端代理调用用户的 API - const apiMessages = conversationHistory.slice(-20).map(function (msg) { - return { role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content }; - }); - - const 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(); + // API Key 模式:浏览器直连用户 API(无需后端代理) + await streamApiKeyReply(text); } else { // 开发编号模式:使用原有后端接口 const res = await fetch(API_BASE + '/api/ps/chat/message', { @@ -137,17 +120,17 @@ async function sendMessage() { }) }); - data = await res.json(); - } + var data = await res.json(); - if (data.reply) { - appendMessage('persona', data.reply); - conversationHistory.push({ role: 'assistant', content: data.reply }); - } + if (data.reply) { + appendMessage('persona', data.reply); + conversationHistory.push({ role: 'assistant', content: data.reply }); + } - if (data.build_ready) { - buildReady = true; - document.getElementById('buildBtn').style.display = 'inline-flex'; + if (data.build_ready) { + buildReady = true; + document.getElementById('buildBtn').style.display = 'inline-flex'; + } } } catch (_err) { appendMessage('system', '消息发送失败,请稍后再试'); @@ -157,6 +140,103 @@ async function sendMessage() { input.focus(); } +/* ---- API Key 流式响应(浏览器直连) ---- */ +async function streamApiKeyReply(text) { + var apiMessages = conversationHistory.slice(-20).map(function (msg) { + return { role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content }; + }); + + var chatUrl = USER_API_BASE + '/chat/completions'; + var reqBody = { + model: SELECTED_MODEL, + messages: apiMessages, + stream: true, + max_tokens: 2000, + temperature: 0.8 + }; + + var res = await fetch(chatUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + USER_API_KEY + }, + body: JSON.stringify(reqBody) + }); + + if (!res.ok) { + var errText = '请求失败 (HTTP ' + res.status + ')'; + try { + var errData = await res.json(); + errText = errData.error?.message || errText; + } catch (_e) { /* ignore parse error */ } + appendMessage('system', '⚠️ ' + errText); + return; + } + + // 流式读取响应 + var streamEl = appendStreamMessage(); + var full = ''; + var reader = res.body.getReader(); + var decoder = new TextDecoder(); + var buf = ''; + + while (true) { + var chunk = await reader.read(); + if (chunk.done) break; + buf += decoder.decode(chunk.value, { stream: true }); + var lines = buf.split('\n'); + buf = lines.pop(); + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (!line.startsWith('data: ')) continue; + var d = line.slice(6); + if (d === '[DONE]') continue; + try { + var parsed = JSON.parse(d); + var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta && parsed.choices[0].delta.content; + if (delta) { + full += delta; + streamEl.textContent = full; + } + } catch (_e) { /* ignore parse error */ } + } + } + + // 如果没有流式内容,尝试非流式解析 + if (!full && buf) { + try { + var jsonRes = JSON.parse(buf); + if (jsonRes.choices && jsonRes.choices[0] && jsonRes.choices[0].message) { + full = jsonRes.choices[0].message.content; + streamEl.textContent = full; + } + } catch (_e) { /* ignore */ } + } + + if (full) { + conversationHistory.push({ role: 'assistant', content: full }); + } else { + streamEl.textContent = '(未收到有效回复)'; + } +} + +/* ---- 创建流式消息气泡 ---- */ +function appendStreamMessage() { + var chatBody = document.getElementById('chatBody'); + var msgDiv = document.createElement('div'); + msgDiv.className = 'message message-persona'; + var contentEl = document.createElement('div'); + contentEl.className = 'msg-content'; + contentEl.textContent = '▋'; + msgDiv.innerHTML = '🧠'; + msgDiv.appendChild(contentEl); + chatBody.appendChild(msgDiv); + chatBody.scrollTop = chatBody.scrollHeight; + return contentEl; +} + /* ---- Key Handler ---- */ function handleKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/persona-studio/frontend/index.html b/persona-studio/frontend/index.html index 0fe20170..d25d6ab4 100644 --- a/persona-studio/frontend/index.html +++ b/persona-studio/frontend/index.html @@ -43,7 +43,7 @@ type="text" id="apiBaseInput" class="apikey-input" - placeholder="API Base URL(如 https://api.openai.com)" + placeholder="API Base URL(可留空自动探测,如 https://api.openai.com/v1)" autocomplete="off" /> const DEV_ID_RE = /^EXP-\d{3,}$/; + const PROBE_TIMEOUT_MS = 8000; const API_BASE = getApiBase(); + /* ---- 已知 API 端点列表(浏览器直连探测) ---- */ + const KNOWN_ENDPOINTS = [ + { label: '云雾 AI', base: 'https://api.yunwu.ai/v1' }, + { label: 'OpenAI', base: 'https://api.openai.com/v1' }, + { label: 'Google Gemini', base: 'https://generativelanguage.googleapis.com/v1beta/openai' }, + { label: 'DeepSeek', base: 'https://api.deepseek.com/v1' }, + { label: 'Moonshot', base: 'https://api.moonshot.cn/v1' }, + { label: '智谱 AI', base: 'https://open.bigmodel.cn/api/paas/v4' }, + ]; + function getApiBase() { if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { return 'http://localhost:3721'; @@ -84,6 +95,18 @@ return 'https://guanghulab.com'; } + /** + * 规范化 API Base URL + * 确保末尾无斜杠,并包含 /v1 路径 + */ + function normalizeApiBase(base) { + var normalized = base.replace(/\/+$/, ''); + if (!normalized.match(/\/v\d+\b/) && !normalized.endsWith('/openai')) { + normalized += '/v1'; + } + return normalized; + } + /* ---- 开发编号登录 ---- */ async function handleLogin(e) { e.preventDefault(); @@ -136,24 +159,18 @@ return false; } - /* ---- API Key 模型检测 ---- */ + /* ---- API Key 模型检测(浏览器直连,无需后端代理) ---- */ async function handleDetectModels() { - const apiBase = document.getElementById('apiBaseInput').value.trim(); - const apiKey = document.getElementById('apiKeyInput').value.trim(); - const errorEl = document.getElementById('errorMsg'); - const statusEl = document.getElementById('detectStatus'); - const modelContainer = document.getElementById('modelListContainer'); - const btn = document.getElementById('detectBtn'); + 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'; @@ -161,59 +178,67 @@ } btn.disabled = true; - btn.textContent = '正在检测可用模型…'; - statusEl.textContent = '正在检测可用模型...'; + btn.textContent = '⏳ 正在检测可用模型…'; statusEl.className = 'detect-status detect-loading'; statusEl.style.display = 'block'; - try { - const res = await fetch(API_BASE + '/api/ps/apikey/detect-models', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ api_base: apiBase, api_key: apiKey }) - }); - - const data = await res.json(); - - if (!res.ok || data.error) { - statusEl.textContent = data.message || '未检测到模型,请检查 API Key'; - statusEl.className = 'detect-status detect-error'; - btn.disabled = false; - btn.textContent = '🔍 检测可用模型'; - return; - } - - const 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 = '🔍 重新检测'; - } catch (fetchErr) { - var errMsg = '检测失败'; - if (fetchErr instanceof TypeError) { - errMsg = '服务器代理未部署或不可达,请联系管理员'; - } else if (fetchErr.name === 'AbortError') { - errMsg = '请求超时,请检查网络连接'; - } else { - errMsg = '网络错误: ' + (fetchErr.message || '请检查网络连接'); - } - statusEl.textContent = errMsg; - statusEl.className = 'detect-status detect-error'; - btn.disabled = false; - btn.textContent = '🔍 检测可用模型'; + /* 构建探测列表:用户端点优先,然后已知端点 */ + var candidates = []; + if (apiBase) { + var userNorm = normalizeApiBase(apiBase); + candidates.push({ label: apiBase, base: userNorm }); } + for (var i = 0; i < KNOWN_ENDPOINTS.length; i++) { + var ep = KNOWN_ENDPOINTS[i]; + if (!apiBase || ep.base !== normalizeApiBase(apiBase)) { + candidates.push(ep); + } + } + + var matched = false; + for (var ci = 0; ci < candidates.length; ci++) { + var candidate = candidates[ci]; + statusEl.textContent = '🔍 探测 ' + candidate.label + '…'; + + try { + var ctrl = new AbortController(); + var tid = setTimeout(function () { ctrl.abort(); }, PROBE_TIMEOUT_MS); + + var res = await fetch(candidate.base + '/models', { + headers: { 'Authorization': 'Bearer ' + apiKey }, + signal: ctrl.signal + }); + clearTimeout(tid); + + if (res.ok) { + var data = await res.json(); + var models = (data.data || []).map(function (m) { return m.id; }).filter(Boolean).sort(); + + if (models.length === 0) continue; + + /* 回填端点地址 */ + document.getElementById('apiBaseInput').value = candidate.base; + + statusEl.textContent = '✅ 检测成功(' + candidate.label + ')· 发现 ' + models.length + ' 个可用模型'; + statusEl.className = 'detect-status detect-success'; + + renderModelList(models, candidate.base, apiKey); + modelContainer.style.display = 'block'; + matched = true; + break; + } + } catch (probeErr) { + /* 单个端点探测失败,继续下一个 */ + } + } + + if (!matched) { + statusEl.textContent = '❌ 未能检测到可用模型,请检查端点地址和密钥是否正确'; + statusEl.className = 'detect-status detect-error'; + } + + btn.disabled = false; + btn.textContent = '🔍 检测可用模型'; } /* ---- 渲染模型列表 ---- */