fix: Persona Studio API detection - switch from backend proxy to client-side direct calls
- Frontend index.html: Replace backend proxy model detection with direct browser fetch to user's API /models endpoint, add KNOWN_ENDPOINTS auto-probing, AbortController timeout - Frontend chat.js: Replace backend proxy chat with direct browser fetch to user's API /chat/completions with streaming support - Backend apikey.js: Add URL format validation (INVALID_API_BASE) and specific error codes (DNS_ERROR, NETWORK_ERROR, TIMEOUT) - Backend server.js: Add /api/health endpoint for smoke test compatibility Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
parent
70db41e17e
commit
3eaf3b2c83
|
|
@ -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') {
|
if (!api_key || typeof api_key !== 'string') {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: true,
|
error: true,
|
||||||
|
|
@ -216,10 +229,22 @@ router.post('/detect-models', async (req, res) => {
|
||||||
cached: false
|
cached: false
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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({
|
res.status(502).json({
|
||||||
error: true,
|
error: true,
|
||||||
code: 'DETECT_FAILED',
|
code: code,
|
||||||
message: err.message || '模型检测失败'
|
message: errMsg
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
app.get('/', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -105,26 +105,9 @@ async function sendMessage() {
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let data;
|
|
||||||
|
|
||||||
if (LOGIN_MODE === 'apikey') {
|
if (LOGIN_MODE === 'apikey') {
|
||||||
// API Key 模式:通过后端代理调用用户的 API
|
// API Key 模式:浏览器直连用户 API(无需后端代理)
|
||||||
const apiMessages = conversationHistory.slice(-20).map(function (msg) {
|
await streamApiKeyReply(text);
|
||||||
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();
|
|
||||||
} else {
|
} else {
|
||||||
// 开发编号模式:使用原有后端接口
|
// 开发编号模式:使用原有后端接口
|
||||||
const res = await fetch(API_BASE + '/api/ps/chat/message', {
|
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) {
|
if (data.reply) {
|
||||||
appendMessage('persona', data.reply);
|
appendMessage('persona', data.reply);
|
||||||
conversationHistory.push({ role: 'assistant', content: data.reply });
|
conversationHistory.push({ role: 'assistant', content: data.reply });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.build_ready) {
|
if (data.build_ready) {
|
||||||
buildReady = true;
|
buildReady = true;
|
||||||
document.getElementById('buildBtn').style.display = 'inline-flex';
|
document.getElementById('buildBtn').style.display = 'inline-flex';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
appendMessage('system', '消息发送失败,请稍后再试');
|
appendMessage('system', '消息发送失败,请稍后再试');
|
||||||
|
|
@ -157,6 +140,103 @@ async function sendMessage() {
|
||||||
input.focus();
|
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 = '<span class="avatar">🧠</span>';
|
||||||
|
msgDiv.appendChild(contentEl);
|
||||||
|
chatBody.appendChild(msgDiv);
|
||||||
|
chatBody.scrollTop = chatBody.scrollHeight;
|
||||||
|
return contentEl;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Key Handler ---- */
|
/* ---- Key Handler ---- */
|
||||||
function handleKeyDown(e) {
|
function handleKeyDown(e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
type="text"
|
type="text"
|
||||||
id="apiBaseInput"
|
id="apiBaseInput"
|
||||||
class="apikey-input"
|
class="apikey-input"
|
||||||
placeholder="API Base URL(如 https://api.openai.com)"
|
placeholder="API Base URL(可留空自动探测,如 https://api.openai.com/v1)"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
|
@ -75,8 +75,19 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const DEV_ID_RE = /^EXP-\d{3,}$/;
|
const DEV_ID_RE = /^EXP-\d{3,}$/;
|
||||||
|
const PROBE_TIMEOUT_MS = 8000;
|
||||||
const API_BASE = getApiBase();
|
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() {
|
function getApiBase() {
|
||||||
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
||||||
return 'http://localhost:3721';
|
return 'http://localhost:3721';
|
||||||
|
|
@ -84,6 +95,18 @@
|
||||||
return 'https://guanghulab.com';
|
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) {
|
async function handleLogin(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -136,24 +159,18 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- API Key 模型检测 ---- */
|
/* ---- API Key 模型检测(浏览器直连,无需后端代理) ---- */
|
||||||
async function handleDetectModels() {
|
async function handleDetectModels() {
|
||||||
const apiBase = document.getElementById('apiBaseInput').value.trim();
|
var apiBase = document.getElementById('apiBaseInput').value.trim();
|
||||||
const apiKey = document.getElementById('apiKeyInput').value.trim();
|
var apiKey = document.getElementById('apiKeyInput').value.trim();
|
||||||
const errorEl = document.getElementById('errorMsg');
|
var errorEl = document.getElementById('errorMsg');
|
||||||
const statusEl = document.getElementById('detectStatus');
|
var statusEl = document.getElementById('detectStatus');
|
||||||
const modelContainer = document.getElementById('modelListContainer');
|
var modelContainer = document.getElementById('modelListContainer');
|
||||||
const btn = document.getElementById('detectBtn');
|
var btn = document.getElementById('detectBtn');
|
||||||
|
|
||||||
errorEl.style.display = 'none';
|
errorEl.style.display = 'none';
|
||||||
modelContainer.style.display = 'none';
|
modelContainer.style.display = 'none';
|
||||||
|
|
||||||
if (!apiBase) {
|
|
||||||
errorEl.textContent = '请输入 API Base URL';
|
|
||||||
errorEl.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
errorEl.textContent = '请输入 API Key';
|
errorEl.textContent = '请输入 API Key';
|
||||||
errorEl.style.display = 'block';
|
errorEl.style.display = 'block';
|
||||||
|
|
@ -161,59 +178,67 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '正在检测可用模型…';
|
btn.textContent = '⏳ 正在检测可用模型…';
|
||||||
statusEl.textContent = '正在检测可用模型...';
|
|
||||||
statusEl.className = 'detect-status detect-loading';
|
statusEl.className = 'detect-status detect-loading';
|
||||||
statusEl.style.display = 'block';
|
statusEl.style.display = 'block';
|
||||||
|
|
||||||
try {
|
/* 构建探测列表:用户端点优先,然后已知端点 */
|
||||||
const res = await fetch(API_BASE + '/api/ps/apikey/detect-models', {
|
var candidates = [];
|
||||||
method: 'POST',
|
if (apiBase) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
var userNorm = normalizeApiBase(apiBase);
|
||||||
body: JSON.stringify({ api_base: apiBase, api_key: apiKey })
|
candidates.push({ label: apiBase, base: userNorm });
|
||||||
});
|
|
||||||
|
|
||||||
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 = '🔍 检测可用模型';
|
|
||||||
}
|
}
|
||||||
|
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 = '🔍 检测可用模型';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 渲染模型列表 ---- */
|
/* ---- 渲染模型列表 ---- */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue