feat: add backend API proxy layer for China network access
- backend-integration/api-proxy.js: Node.js proxy with SSE streaming passthrough - Supports DeepSeek/Moonshot/Zhipu (China-direct) + Yunwu/OpenAI/Gemini - API keys managed server-side via environment variables - Rate limiting (10 req/min per IP) - CORS support for cross-origin access - Health check and model listing endpoints - backend-integration/nginx-api-proxy.conf: Nginx config reference - docs/index.html: Added "后端代理" provider option (no API key required) - package.json: Added proxy:start script Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
parent
aac7f53d4b
commit
723f6b16bc
|
|
@ -3,3 +3,55 @@
|
||||||
- 状态:进行中
|
- 状态:进行中
|
||||||
- 技术栈:Node.js
|
- 技术栈:Node.js
|
||||||
- 依赖模块:所有前端模块
|
- 依赖模块:所有前端模块
|
||||||
|
|
||||||
|
## API 代理层(api-proxy.js)
|
||||||
|
|
||||||
|
解决国内开发者无法直连海外模型 API + 前端 Key 暴露问题。
|
||||||
|
|
||||||
|
### 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 配置 API 密钥(推荐先用 DeepSeek,国内直连)
|
||||||
|
export DEEPSEEK_API_KEY=sk-xxx
|
||||||
|
|
||||||
|
# 2. 启动代理
|
||||||
|
node backend-integration/api-proxy.js
|
||||||
|
# 或用 PM2
|
||||||
|
pm2 start backend-integration/api-proxy.js --name api-proxy
|
||||||
|
|
||||||
|
# 3. 测试
|
||||||
|
curl http://localhost:3721/api/health
|
||||||
|
curl http://localhost:3721/api/models
|
||||||
|
```
|
||||||
|
|
||||||
|
### 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/chat` | 聊天代理(SSE 流式透传) |
|
||||||
|
| GET | `/api/models` | 列出已配置的可用模型 |
|
||||||
|
| GET | `/api/health` | 健康检查 |
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `DEEPSEEK_API_KEY` | DeepSeek 密钥(国内直连,推荐首选) |
|
||||||
|
| `MOONSHOT_API_KEY` | Moonshot/Kimi 密钥(国内直连) |
|
||||||
|
| `ZHIPU_API_KEY` | 智谱 AI 密钥(国内直连) |
|
||||||
|
| `YUNWU_API_KEY` | 云雾 AI 密钥(团队推荐) |
|
||||||
|
| `OPENAI_API_KEY` | OpenAI 密钥(需海外服务器) |
|
||||||
|
| `GEMINI_API_KEY` | Google Gemini 密钥(需海外服务器) |
|
||||||
|
| `PROXY_PORT` | 端口(默认 3721) |
|
||||||
|
| `RATE_LIMIT_RPM` | 频率限制(默认 10 次/分钟) |
|
||||||
|
|
||||||
|
### Nginx 配置
|
||||||
|
|
||||||
|
参见 `nginx-api-proxy.conf`,将 `/api/*` 请求代理到 Node.js 服务。
|
||||||
|
|
||||||
|
### 前端对接
|
||||||
|
|
||||||
|
前端铸渊聊天室(docs/index.html)已内置「后端代理模式」:
|
||||||
|
- 在设置页面选择「🔌 后端代理」提供商
|
||||||
|
- 自动调用 `/api/chat`,无需用户填写 API Key
|
||||||
|
- Key 由后端环境变量管理,不暴露给前端
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
/**
|
||||||
|
* 🔌 AGE OS · API 代理层
|
||||||
|
*
|
||||||
|
* 解决两个核心问题:
|
||||||
|
* 1. 国内开发者无法直连海外模型API(被墙)
|
||||||
|
* 2. API Key 不应暴露在前端 JavaScript 中
|
||||||
|
*
|
||||||
|
* 架构:
|
||||||
|
* 浏览器 → guanghulab.com/api/chat → 本代理 → 真实模型API
|
||||||
|
*
|
||||||
|
* 支持的模型:
|
||||||
|
* - deepseek (api.deepseek.com) — 国内直连,推荐首选
|
||||||
|
* - moonshot (api.moonshot.cn) — 国内直连
|
||||||
|
* - zhipu (open.bigmodel.cn) — 国内直连
|
||||||
|
* - yunwu (api.yunwu.ai) — 团队推荐
|
||||||
|
* - openai (api.openai.com) — 需海外服务器
|
||||||
|
* - gemini (generativelanguage.googleapis.com) — 需海外服务器
|
||||||
|
*
|
||||||
|
* 环境变量:
|
||||||
|
* DEEPSEEK_API_KEY — DeepSeek API 密钥
|
||||||
|
* MOONSHOT_API_KEY — Moonshot/Kimi API 密钥
|
||||||
|
* ZHIPU_API_KEY — 智谱 API 密钥
|
||||||
|
* YUNWU_API_KEY — 云雾 AI API 密钥
|
||||||
|
* OPENAI_API_KEY — OpenAI API 密钥
|
||||||
|
* GEMINI_API_KEY — Google Gemini API 密钥
|
||||||
|
* PROXY_PORT — 代理服务端口(默认 3721)
|
||||||
|
* ALLOWED_ORIGINS — CORS 允许的域名(逗号分隔,默认 *)
|
||||||
|
* RATE_LIMIT_RPM — 每用户每分钟最大请求数(默认 10)
|
||||||
|
*
|
||||||
|
* 启动:
|
||||||
|
* node backend-integration/api-proxy.js
|
||||||
|
* 或通过 PM2: pm2 start backend-integration/api-proxy.js --name api-proxy
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 配置
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
const PORT = parseInt(process.env.PROXY_PORT || '3721', 10);
|
||||||
|
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*').split(',').map(s => s.trim());
|
||||||
|
const RATE_LIMIT_RPM = parseInt(process.env.RATE_LIMIT_RPM || '10', 10);
|
||||||
|
|
||||||
|
// 模型端点映射
|
||||||
|
const MODEL_CONFIG = {
|
||||||
|
deepseek: {
|
||||||
|
base: 'https://api.deepseek.com/v1',
|
||||||
|
keyEnv: 'DEEPSEEK_API_KEY',
|
||||||
|
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||||
|
label: 'DeepSeek(国内直连)'
|
||||||
|
},
|
||||||
|
moonshot: {
|
||||||
|
base: 'https://api.moonshot.cn/v1',
|
||||||
|
keyEnv: 'MOONSHOT_API_KEY',
|
||||||
|
models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
|
||||||
|
label: 'Moonshot/Kimi(国内直连)'
|
||||||
|
},
|
||||||
|
zhipu: {
|
||||||
|
base: 'https://open.bigmodel.cn/api/paas/v4',
|
||||||
|
keyEnv: 'ZHIPU_API_KEY',
|
||||||
|
models: ['glm-4', 'glm-4-flash', 'glm-4-air'],
|
||||||
|
label: '智谱AI/GLM(国内直连)'
|
||||||
|
},
|
||||||
|
yunwu: {
|
||||||
|
base: 'https://api.yunwu.ai/v1',
|
||||||
|
keyEnv: 'YUNWU_API_KEY',
|
||||||
|
models: ['gpt-4o', 'gpt-4o-mini', 'claude-3-5-sonnet-20241022', 'deepseek-chat', 'gemini-1.5-pro'],
|
||||||
|
label: '云雾AI(团队推荐)'
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
base: 'https://api.openai.com/v1',
|
||||||
|
keyEnv: 'OPENAI_API_KEY',
|
||||||
|
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||||
|
label: 'OpenAI(需海外服务器)'
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
base: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||||
|
keyEnv: 'GEMINI_API_KEY',
|
||||||
|
models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-pro', 'gemini-1.5-flash'],
|
||||||
|
label: 'Google Gemini(需海外服务器)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 频率限制(内存级,基于 IP)
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
const rateLimitMap = new Map();
|
||||||
|
|
||||||
|
function checkRateLimit(clientId) {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowMs = 60 * 1000; // 1 minute window
|
||||||
|
|
||||||
|
if (!rateLimitMap.has(clientId)) {
|
||||||
|
rateLimitMap.set(clientId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = rateLimitMap.get(clientId);
|
||||||
|
// Remove entries older than the window
|
||||||
|
while (timestamps.length > 0 && timestamps[0] < now - windowMs) {
|
||||||
|
timestamps.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamps.length >= RATE_LIMIT_RPM) {
|
||||||
|
return false; // Rate limited
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.push(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old entries every 5 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const cutoff = Date.now() - 120 * 1000;
|
||||||
|
for (const [key, timestamps] of rateLimitMap.entries()) {
|
||||||
|
const filtered = timestamps.filter(t => t > cutoff);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
rateLimitMap.delete(key);
|
||||||
|
} else {
|
||||||
|
rateLimitMap.set(key, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// CORS 处理
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function setCorsHeaders(res, origin) {
|
||||||
|
const allowed = ALLOWED_ORIGINS.includes('*') || ALLOWED_ORIGINS.includes(origin);
|
||||||
|
if (allowed) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-User-Id');
|
||||||
|
res.setHeader('Access-Control-Max-Age', '86400');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 请求体解析
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function parseBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = '';
|
||||||
|
let size = 0;
|
||||||
|
const MAX_BODY = 512 * 1024; // 512KB max
|
||||||
|
|
||||||
|
req.on('data', chunk => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY) {
|
||||||
|
reject(new Error('请求体过大'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(body ? JSON.parse(body) : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('无效的 JSON 请求体'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// JSON 响应
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function jsonResponse(res, status, data) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 获取客户端 IP
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function getClientId(req) {
|
||||||
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
|| req.headers['x-real-ip']
|
||||||
|
|| req.socket.remoteAddress
|
||||||
|
|| 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 上游 HTTPS 请求(流式透传)
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function proxyStream(upstreamUrl, apiKey, requestBody, res) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(upstreamUrl);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 443,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Accept': 'text/event-stream'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyStr = JSON.stringify(requestBody);
|
||||||
|
options.headers['Content-Length'] = Buffer.byteLength(bodyStr);
|
||||||
|
|
||||||
|
const upstream = https.request(options, (upstreamRes) => {
|
||||||
|
// Forward status and content-type
|
||||||
|
const ct = upstreamRes.headers['content-type'] || 'text/event-stream';
|
||||||
|
res.writeHead(upstreamRes.statusCode, {
|
||||||
|
'Content-Type': ct,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no' // Nginx: disable buffering for SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipe upstream response directly to client (streaming)
|
||||||
|
upstreamRes.on('data', chunk => {
|
||||||
|
res.write(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamRes.on('end', () => {
|
||||||
|
res.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamRes.on('error', err => {
|
||||||
|
console.error('上游响应错误:', err.message);
|
||||||
|
res.end();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.on('error', err => {
|
||||||
|
console.error('上游连接错误:', err.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
jsonResponse(res, 502, {
|
||||||
|
error: true,
|
||||||
|
code: 'UPSTREAM_ERROR',
|
||||||
|
message: '无法连接模型API: ' + err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set timeout (30 seconds for connection, but streaming can be longer)
|
||||||
|
upstream.setTimeout(30000, () => {
|
||||||
|
upstream.destroy(new Error('连接超时'));
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.write(bodyStr);
|
||||||
|
upstream.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 路由处理器
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// GET /api/models — 列出可用模型
|
||||||
|
function handleGetModels(req, res) {
|
||||||
|
const available = {};
|
||||||
|
for (const [provider, config] of Object.entries(MODEL_CONFIG)) {
|
||||||
|
const key = process.env[config.keyEnv];
|
||||||
|
if (key) {
|
||||||
|
available[provider] = {
|
||||||
|
label: config.label,
|
||||||
|
models: config.models
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonResponse(res, 200, {
|
||||||
|
providers: available,
|
||||||
|
default_provider: Object.keys(available)[0] || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/chat — 代理转发聊天请求
|
||||||
|
async function handleChat(req, res) {
|
||||||
|
const clientId = getClientId(req);
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
if (!checkRateLimit(clientId)) {
|
||||||
|
jsonResponse(res, 429, {
|
||||||
|
error: true,
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: `请求过于频繁,每分钟最多 ${RATE_LIMIT_RPM} 次请求`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await parseBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
jsonResponse(res, 400, {
|
||||||
|
error: true,
|
||||||
|
code: 'INVALID_BODY',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { provider, model, messages, system, stream, temperature, max_tokens } = body;
|
||||||
|
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
jsonResponse(res, 400, {
|
||||||
|
error: true,
|
||||||
|
code: 'MISSING_MESSAGES',
|
||||||
|
message: '缺少 messages 参数'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine provider
|
||||||
|
const prov = provider || 'deepseek';
|
||||||
|
const config = MODEL_CONFIG[prov];
|
||||||
|
if (!config) {
|
||||||
|
jsonResponse(res, 400, {
|
||||||
|
error: true,
|
||||||
|
code: 'INVALID_PROVIDER',
|
||||||
|
message: `不支持的模型提供商: ${prov},可选: ${Object.keys(MODEL_CONFIG).join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API key from environment
|
||||||
|
const apiKey = process.env[config.keyEnv];
|
||||||
|
if (!apiKey) {
|
||||||
|
jsonResponse(res, 503, {
|
||||||
|
error: true,
|
||||||
|
code: 'NO_API_KEY',
|
||||||
|
message: `${config.label} 的 API 密钥未配置,请联系管理员`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build upstream request
|
||||||
|
const mdl = model || config.models[0];
|
||||||
|
const upstreamBody = {
|
||||||
|
model: mdl,
|
||||||
|
messages: messages,
|
||||||
|
stream: stream !== false, // Default to streaming
|
||||||
|
temperature: temperature ?? 0.8,
|
||||||
|
max_tokens: max_tokens ?? 2000
|
||||||
|
};
|
||||||
|
|
||||||
|
// If system prompt provided, prepend as system message
|
||||||
|
if (system && !messages.find(m => m.role === 'system')) {
|
||||||
|
upstreamBody.messages = [{ role: 'system', content: system }, ...messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = config.base + '/chat/completions';
|
||||||
|
|
||||||
|
console.log(`[代理] ${clientId} → ${prov}/${mdl} (${messages.length}条消息)`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await proxyStream(upstreamUrl, apiKey, upstreamBody, res);
|
||||||
|
} catch (err) {
|
||||||
|
// Error already handled in proxyStream
|
||||||
|
console.error(`[代理] 请求失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/health — 健康检查
|
||||||
|
function handleHealth(req, res) {
|
||||||
|
const configured = Object.entries(MODEL_CONFIG)
|
||||||
|
.filter(([, config]) => process.env[config.keyEnv])
|
||||||
|
.map(([name, config]) => ({ provider: name, label: config.label }));
|
||||||
|
|
||||||
|
jsonResponse(res, 200, {
|
||||||
|
status: 'ok',
|
||||||
|
version: '1.0.0',
|
||||||
|
service: 'AGE OS API Proxy',
|
||||||
|
configured_providers: configured,
|
||||||
|
rate_limit: RATE_LIMIT_RPM + ' req/min'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// HTTP 服务器
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const origin = req.headers.origin || '';
|
||||||
|
setCorsHeaders(res, origin);
|
||||||
|
|
||||||
|
// Handle preflight
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (path === '/api/chat' && req.method === 'POST') {
|
||||||
|
await handleChat(req, res);
|
||||||
|
} else if (path === '/api/models' && req.method === 'GET') {
|
||||||
|
handleGetModels(req, res);
|
||||||
|
} else if (path === '/api/health' && req.method === 'GET') {
|
||||||
|
handleHealth(req, res);
|
||||||
|
} else {
|
||||||
|
jsonResponse(res, 404, {
|
||||||
|
error: true,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: '接口不存在。可用接口: POST /api/chat, GET /api/models, GET /api/health'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('服务器错误:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
jsonResponse(res, 500, {
|
||||||
|
error: true,
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '服务器内部错误'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`\n🔌 AGE OS API 代理层已启动`);
|
||||||
|
console.log(` 端口: ${PORT}`);
|
||||||
|
console.log(` 地址: http://localhost:${PORT}`);
|
||||||
|
console.log(` 频率限制: ${RATE_LIMIT_RPM} 次/分钟`);
|
||||||
|
console.log(` CORS: ${ALLOWED_ORIGINS.join(', ')}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Show configured providers
|
||||||
|
let hasAny = false;
|
||||||
|
for (const [name, config] of Object.entries(MODEL_CONFIG)) {
|
||||||
|
const key = process.env[config.keyEnv];
|
||||||
|
const status = key ? '✅ 已配置' : '❌ 未配置';
|
||||||
|
const china = ['deepseek', 'moonshot', 'zhipu'].includes(name) ? '🇨🇳' : '🌐';
|
||||||
|
console.log(` ${china} ${config.label}: ${status}`);
|
||||||
|
if (key) hasAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAny) {
|
||||||
|
console.log('\n ⚠️ 未配置任何 API 密钥!');
|
||||||
|
console.log(' 请设置环境变量(推荐先配 DeepSeek,国内直连):');
|
||||||
|
console.log(' export DEEPSEEK_API_KEY=sk-xxx');
|
||||||
|
console.log(' export YUNWU_API_KEY=sk-xxx');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n 可用接口:');
|
||||||
|
console.log(' POST /api/chat — 聊天代理(SSE 流式)');
|
||||||
|
console.log(' GET /api/models — 列出可用模型');
|
||||||
|
console.log(' GET /api/health — 健康检查');
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 🔌 AGE OS API 代理层 — Nginx 配置参考
|
||||||
|
#
|
||||||
|
# 将此配置添加到 guanghulab.com 的 Nginx server block 中,
|
||||||
|
# 使前端可以通过 /api/* 路径访问后端代理。
|
||||||
|
#
|
||||||
|
# 前端(GitHub Pages)→ Nginx → API 代理(Node.js :3721)→ 模型 API
|
||||||
|
|
||||||
|
# API 代理转发
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3721;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# SSE 流式响应必需
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
|
||||||
|
# 长连接超时(AI 生成可能需要较长时间)
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
@ -323,12 +323,13 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label>选择 AI 提供商</label>
|
<label>选择 AI 提供商</label>
|
||||||
<select id="sp" onchange="onProv(this.value,'s')">
|
<select id="sp" onchange="onProv(this.value,'s')">
|
||||||
|
<option value="proxy">🔌 后端代理(国内直连·推荐)</option>
|
||||||
<option value="yunwu">☁️ 云雾 AI(团队推荐)</option>
|
<option value="yunwu">☁️ 云雾 AI(团队推荐)</option>
|
||||||
<option value="openai">OpenAI (ChatGPT)</option>
|
<option value="openai">OpenAI (ChatGPT)</option>
|
||||||
<option value="gemini">🔮 Google Gemini</option>
|
<option value="gemini">🔮 Google Gemini</option>
|
||||||
<option value="deepseek">DeepSeek(深度求索)</option>
|
<option value="deepseek">DeepSeek(深度求索·国内直连)</option>
|
||||||
<option value="moonshot">Moonshot(Kimi)</option>
|
<option value="moonshot">Moonshot(Kimi·国内直连)</option>
|
||||||
<option value="zhipu">智谱 AI(GLM)</option>
|
<option value="zhipu">智谱 AI(GLM·国内直连)</option>
|
||||||
<option value="custom">自定义 / 其他兼容接口</option>
|
<option value="custom">自定义 / 其他兼容接口</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -514,12 +515,13 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
|
||||||
<div class="sfg">
|
<div class="sfg">
|
||||||
<label>AI 提供商</label>
|
<label>AI 提供商</label>
|
||||||
<select id="cp" onchange="onProv(this.value,'c')">
|
<select id="cp" onchange="onProv(this.value,'c')">
|
||||||
|
<option value="proxy">🔌 后端代理(国内直连·推荐)</option>
|
||||||
<option value="yunwu">☁️ 云雾 AI(团队推荐)</option>
|
<option value="yunwu">☁️ 云雾 AI(团队推荐)</option>
|
||||||
<option value="openai">OpenAI (ChatGPT)</option>
|
<option value="openai">OpenAI (ChatGPT)</option>
|
||||||
<option value="gemini">🔮 Google Gemini</option>
|
<option value="gemini">🔮 Google Gemini</option>
|
||||||
<option value="deepseek">DeepSeek(深度求索)</option>
|
<option value="deepseek">DeepSeek(深度求索·国内直连)</option>
|
||||||
<option value="moonshot">Moonshot(Kimi)</option>
|
<option value="moonshot">Moonshot(Kimi·国内直连)</option>
|
||||||
<option value="zhipu">智谱 AI(GLM)</option>
|
<option value="zhipu">智谱 AI(GLM·国内直连)</option>
|
||||||
<option value="custom">自定义</option>
|
<option value="custom">自定义</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -719,6 +721,7 @@ const ROLE_MAP = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROVS = {
|
const PROVS = {
|
||||||
|
proxy: {base:'/api', mdls:['deepseek-chat','gpt-4o','moonshot-v1-8k','glm-4'],isProxy:true},
|
||||||
yunwu: {base:'https://api.yunwu.ai/v1', mdls:['gpt-4o','gpt-4o-mini','claude-3-5-sonnet-20241022','deepseek-chat','gemini-1.5-pro']},
|
yunwu: {base:'https://api.yunwu.ai/v1', mdls:['gpt-4o','gpt-4o-mini','claude-3-5-sonnet-20241022','deepseek-chat','gemini-1.5-pro']},
|
||||||
openai: {base:'https://api.openai.com/v1', mdls:['gpt-4o','gpt-4o-mini','gpt-4-turbo','gpt-3.5-turbo']},
|
openai: {base:'https://api.openai.com/v1', mdls:['gpt-4o','gpt-4o-mini','gpt-4-turbo','gpt-3.5-turbo']},
|
||||||
gemini: {base:'https://generativelanguage.googleapis.com/v1beta/openai', mdls:['gemini-2.0-flash','gemini-2.0-flash-lite','gemini-1.5-pro','gemini-1.5-flash']},
|
gemini: {base:'https://generativelanguage.googleapis.com/v1beta/openai', mdls:['gemini-2.0-flash','gemini-2.0-flash-lite','gemini-1.5-pro','gemini-1.5-flash']},
|
||||||
|
|
@ -1105,7 +1108,8 @@ async function boot(){
|
||||||
localStorage.removeItem('zy_key');
|
localStorage.removeItem('zy_key');
|
||||||
initSetupUI();
|
initSetupUI();
|
||||||
if(A.userName) A.userMeta = ROLE_MAP[A.userName]||null;
|
if(A.userName) A.userMeta = ROLE_MAP[A.userName]||null;
|
||||||
if(A.key||A.demo){
|
const isProxy = PROVS[A.prov]?.isProxy;
|
||||||
|
if(A.key||A.demo||isProxy){
|
||||||
showApp();
|
showApp();
|
||||||
} else {
|
} else {
|
||||||
if(A.userName) document.getElementById('suid').value=A.userName;
|
if(A.userName) document.getElementById('suid').value=A.userName;
|
||||||
|
|
@ -1164,8 +1168,10 @@ function onProv(pv,ctx){
|
||||||
const mdlHint = ctx==='s'?'sm-cust-hint':'cm-cust-hint';
|
const mdlHint = ctx==='s'?'sm-cust-hint':'cm-cust-hint';
|
||||||
const epGrp = ctx==='s'?'sep-g':'cep-g';
|
const epGrp = ctx==='s'?'sep-g':'cep-g';
|
||||||
const epInp = ctx==='s'?'sep':'cep';
|
const epInp = ctx==='s'?'sep':'cep';
|
||||||
|
const keyInp = ctx==='s'?'sk':'ck';
|
||||||
const cur = ctx==='s'? A.mdl : document.getElementById('cm')?.value;
|
const cur = ctx==='s'? A.mdl : document.getElementById('cm')?.value;
|
||||||
const isCustom = pv==='custom';
|
const isCustom = pv==='custom';
|
||||||
|
const isProxy = PROVS[pv]?.isProxy;
|
||||||
document.getElementById(mdlSel).style.display = isCustom?'none':'block';
|
document.getElementById(mdlSel).style.display = isCustom?'none':'block';
|
||||||
document.getElementById(mdlCust).style.display = isCustom?'block':'none';
|
document.getElementById(mdlCust).style.display = isCustom?'block':'none';
|
||||||
document.getElementById(mdlHint).style.display = isCustom?'block':'none';
|
document.getElementById(mdlHint).style.display = isCustom?'block':'none';
|
||||||
|
|
@ -1180,6 +1186,9 @@ function onProv(pv,ctx){
|
||||||
const ep = document.getElementById(epInp);
|
const ep = document.getElementById(epInp);
|
||||||
if(ep) ep.value = PROVS[pv]?.base||'';
|
if(ep) ep.value = PROVS[pv]?.base||'';
|
||||||
}
|
}
|
||||||
|
// Proxy mode: API key is optional (managed server-side)
|
||||||
|
const ki = document.getElementById(keyInp);
|
||||||
|
if(ki) ki.placeholder = isProxy ? '后端代理模式 — 无需填写 API 密钥(服务器端统一管理)' : (ctx==='s'?'粘贴任意 API 密钥,点击右侧按钮自动识别提供商和模型':'留空 = 保持当前密钥不变;粘贴新密钥即替换');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillModels(selId, pv, cur){
|
function fillModels(selId, pv, cur){
|
||||||
|
|
@ -1270,18 +1279,21 @@ async function autoDetectAPI(ctx){
|
||||||
}
|
}
|
||||||
|
|
||||||
function doSetup(){
|
function doSetup(){
|
||||||
|
const pv = document.getElementById('sp').value;
|
||||||
|
const isProxy = PROVS[pv]?.isProxy;
|
||||||
const k = document.getElementById('sk').value.trim();
|
const k = document.getElementById('sk').value.trim();
|
||||||
const errEl = document.getElementById('sk-err');
|
const errEl = document.getElementById('sk-err');
|
||||||
if(!k){errEl.style.display='block';return;}
|
// Proxy mode doesn't require an API key
|
||||||
|
if(!k && !isProxy){errEl.style.display='block';return;}
|
||||||
errEl.style.display='none';
|
errEl.style.display='none';
|
||||||
const pv = document.getElementById('sp').value;
|
|
||||||
const md = pv==='custom'
|
const md = pv==='custom'
|
||||||
? ((document.getElementById('sm-cust')?.value||'').trim()||DEFAULT_MDL)
|
? ((document.getElementById('sm-cust')?.value||'').trim()||DEFAULT_MDL)
|
||||||
: document.getElementById('sm').value;
|
: document.getElementById('sm').value;
|
||||||
let base = PROVS[pv]?.base||'';
|
let base = PROVS[pv]?.base||'';
|
||||||
if(pv==='custom') base=(document.getElementById('sep')?.value||'').trim()||base;
|
if(pv==='custom') base=(document.getElementById('sep')?.value||'').trim()||base;
|
||||||
A.key=k; A.base=base; A.mdl=md; A.prov=pv; A.demo=false;
|
A.key=k; A.base=base; A.mdl=md; A.prov=pv; A.demo=false;
|
||||||
sessionStorage.setItem('zy_key', k); lss('zy_base',base); lss('zy_mdl',md); lss('zy_prov',pv);
|
if(k) sessionStorage.setItem('zy_key', k);
|
||||||
|
lss('zy_base',base); lss('zy_mdl',md); lss('zy_prov',pv);
|
||||||
const un = document.getElementById('suid').value;
|
const un = document.getElementById('suid').value;
|
||||||
const gh = (document.getElementById('sghuser')?.value||'').trim();
|
const gh = (document.getElementById('sghuser')?.value||'').trim();
|
||||||
setIdentity(un, gh);
|
setIdentity(un, gh);
|
||||||
|
|
@ -1568,14 +1580,17 @@ async function streamReply(txt){
|
||||||
updSendBtn();
|
updSendBtn();
|
||||||
const el=addStreamBubble();
|
const el=addStreamBubble();
|
||||||
try{
|
try{
|
||||||
const res=await fetch(A.base+'/chat/completions',{
|
const isProxy = PROVS[A.prov]?.isProxy;
|
||||||
|
const url = isProxy ? (A.base+'/chat') : (A.base+'/chat/completions');
|
||||||
|
const headers = {'Content-Type':'application/json'};
|
||||||
|
if(!isProxy && A.key) headers['Authorization'] = 'Bearer '+A.key;
|
||||||
|
const reqBody = isProxy
|
||||||
|
? {provider:A.prov==='proxy'?undefined:A.prov, model:A.mdl, messages:[{role:'system',content:sysPrompt()},...A.msgs], stream:true, temperature:0.8, max_tokens:2000, system:sysPrompt()}
|
||||||
|
: {model:A.mdl, messages:[{role:'system',content:sysPrompt()},...A.msgs], stream:true, temperature:0.8, max_tokens:2000};
|
||||||
|
const res=await fetch(url,{
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json','Authorization':'Bearer '+A.key},
|
headers:headers,
|
||||||
body:JSON.stringify({
|
body:JSON.stringify(reqBody),
|
||||||
model:A.mdl,
|
|
||||||
messages:[{role:'system',content:sysPrompt()},...A.msgs],
|
|
||||||
stream:true,temperature:0.8,max_tokens:2000,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if(!res.ok){
|
if(!res.ok){
|
||||||
const provLabel = A.prov==='custom' ? A.base : (A.prov+'('+A.base+')');
|
const provLabel = A.prov==='custom' ? A.base : (A.prov+'('+A.base+')');
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"esp:process": "node scripts/esp-email-processor.js",
|
"esp:process": "node scripts/esp-email-processor.js",
|
||||||
"psp:inspect": "node scripts/psp-inspection.js",
|
"psp:inspect": "node scripts/psp-inspection.js",
|
||||||
"repo:map": "node scripts/generate-repo-map.js",
|
"repo:map": "node scripts/generate-repo-map.js",
|
||||||
"deploy:agent": "node scripts/bingshuo-deploy-agent.js"
|
"deploy:agent": "node scripts/bingshuo-deploy-agent.js",
|
||||||
|
"proxy:start": "node backend-integration/api-proxy.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"imapflow": "^1.2.12",
|
"imapflow": "^1.2.12",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue