Concentra todas tus claves API en un dispositivo inactivo y escribe solo una dirección en el móvil — chat IA, programación IA y generación de plugins IA, todo conectado sin fricción.
Las herramientas de IA de CosmosBox (chat IA, programación IA, generación de plugins IA) funcionan en modo BYOK: tú aportas las claves API de cada proveedor. Conectar directamente con varios proveedores significa una clave y una BaseURL por proveedor, y cambiar de configuración cada vez que cambias de modelo.
Un relay propio lo concentra todo en una sola dirección: las claves de cada proveedor se configuran una vez en el dispositivo relay, y en el móvil solo escribes una dirección + una clave de relay. Los nombres de modelo se enrutan automáticamente al proveedor correcto. Además, el relay reintenta con retroceso exponencial cuando un proveedor está ocupado, lo que hace más estable la experiencia con redes débiles.
| Aspecto | Directa a varios proveedores | Con relay |
|---|---|---|
| Configuración en la app | Una clave y una dirección por proveedor | Solo una dirección + una clave |
| Cambiar de modelo | Cambiar la configuración manualmente | El nombre del modelo se enruta solo |
| Proveedor ocupado (429) | Falla al momento, reintento manual | Reintento automático con retroceso exponencial |
| Gestión de claves | Repartidas entre dispositivos | Solo en el dispositivo relay |
El relay es un servicio de reenvío muy ligero compatible con OpenAI que se ejecuta en tu propio dispositivo. Tu petición va primero al relay; este encuentra el proveedor según el nombre del modelo, la reenvía y transmite el resultado en streaming de vuelta al móvil. Todo el servicio es un único archivo Node.js sin dependencias — sin paquetes de terceros, sin base de datos.
Copia el script install-termux.sh de abajo por completo (o consíguelo del proyecto AI relay) y ejecútalo en Termux: el script instala Node.js, genera la configuración con un asistente interactivo (escribes las claves de cada proveedor una a una), arranca el relay e imprime los ajustes para tu móvil.
#!/data/data/com.termux/files/usr/bin/bash
# ============================================================
# 宇宙核 AI 中继 · Termux 一键安装脚本(旧手机 / 电视盒子)
# 用法:在 Termux 里执行 bash install-termux.sh
# 或先 curl 下载本脚本再执行。
# 步骤:装 Node → 生成 config.json(向导式)→ 启动 → 打印配置信息
# ============================================================
set -e
echo "========================================"
echo " 宇宙核 AI 中继 · 一键安装"
echo "========================================"
# ---------- 1. 安装 Node.js ----------
if ! command -v node >/dev/null 2>&1; then
echo ""
echo "[1/4] 安装 Node.js(约 1-2 分钟)..."
pkg update -y
pkg install -y nodejs
else
echo "[1/4] Node.js 已安装: $(node -v)"
fi
# ---------- 2. 准备中继文件 ----------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RELAY="$SCRIPT_DIR/relay.mjs"
if [ ! -f "$RELAY" ]; then
echo ""
echo "[2/4] 下载 relay.mjs ..."
# 官网教程页有完整代码;此处尝试常见来源(无则提示手动创建)
curl -fsSL -o "$RELAY" https://yuzhouhe.com/relay/relay.mjs \
|| echo "⚠️ 自动下载失败:请从官网教程页复制 relay.mjs 代码保存为 $RELAY"
if [ ! -f "$RELAY" ]; then
echo "请先解决 relay.mjs 缺失,再重新运行本脚本。"
exit 1
fi
else
echo "[2/4] relay.mjs 已存在"
fi
# ---------- 3. 配置向导 ----------
CONFIG="$SCRIPT_DIR/config.json"
if [ ! -f "$CONFIG" ]; then
echo ""
echo "[3/4] 首次运行,创建配置(各家 API Key 只保存在本机,不会上传)..."
read -rp "中继端口(默认 8000): " PORT
PORT="${PORT:-8000}"
read -rp "中继 Key(手机 App 里要填,建议 16 位以上随机字符): " RELAY_KEY
while [ -z "$RELAY_KEY" ]; do
read -rp "中继 Key 不能为空,请重新输入: " RELAY_KEY
done
cat > "$CONFIG" <<EOF
{
"port": $PORT,
"relayKey": "$RELAY_KEY",
"timeoutSeconds": 300,
"maxRetries": 2,
"log": true,
"upstreams": [
EOF
echo " 现在逐个添加上游服务商(留空服务商名称 = 完成)"
FIRST=1
while true; do
read -rp "服务商名称(如 deepseek / qwen / kimi,留空结束): " NAME
[ -z "$NAME" ] && break
read -rp " $NAME 的 BaseURL(如 https://api.deepseek.com): " BASE
read -rp " $NAME 的 API Key: " KEY
read -rp " 模型列表(逗号分隔,如 deepseek-v4-flash,deepseek-chat): " MODELS
if [ -n "$BASE" ] && [ -n "$KEY" ] && [ -n "$MODELS" ]; then
[ "$FIRST" = "0" ] && echo " ," >> "$CONFIG"
cat >> "$CONFIG" <<EOF
{
"name": "$NAME",
"baseUrl": "$BASE",
"apiKey": "$KEY",
"models": [$(echo "$MODELS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/.*/"&"/' | paste -sd,)]
}
EOF
FIRST=0
else
echo " ⚠️ 信息不完整,跳过该服务商"
fi
done
echo " ]" >> "$CONFIG"
echo "}" >> "$CONFIG"
else
echo "[3/4] config.json 已存在(如需修改直接编辑它)"
fi
# ---------- 4. 启动 ----------
echo ""
echo "[4/4] 启动中继..."
termux-wake-lock 2>/dev/null || true # 防止息屏断网(不支持时忽略)
# 后台启动(关闭 Termux 窗口后继续运行;重启手机后需重新运行本脚本)
nohup node "$RELAY" "$CONFIG" > "$SCRIPT_DIR/relay.log" 2>&1 &
sleep 1
if pgrep -f "node .*relay.mjs" >/dev/null 2>&1; then
echo ""
echo "✅ 中继已启动!日志: $SCRIPT_DIR/relay.log"
else
echo "❌ 启动失败,请查看 $SCRIPT_DIR/relay.log"
exit 1
fi
# 显示局域网地址
IP=$(ifconfig 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' | head -1 | awk '{print $2}')
PORT=$(grep '"port"' "$CONFIG" | grep -oE '[0-9]+' | head -1)
echo ""
echo "========================================"
echo " 在「宇宙核」App 里这样配置:"
echo " 1. AI 设置 → 我的服务商 → 添加服务商"
echo " 2. 名称:中继"
echo " 3. BaseURL:http://$IP:$PORT/v1"
echo " 4. API Key:你在上一步设置的中继 Key"
echo " 5. 模型:你在 config.json 里配置的任意模型名"
echo " 手机与这台设备需在同一 Wi-Fi"
echo "========================================"
¿No quieres el script de un clic, o prefieres un ordenador / NAS? Tres pasos manuales. Los dos bloques de código de abajo son el programa completo del relay y un ejemplo de configuración: cópialos, guárdalos y sigue los pasos.
{
"port": 8000,
"relayKey": "改成一段长随机字符串(手机 App 里要填它)",
"timeoutSeconds": 300,
"maxRetries": 2,
"log": true,
"upstreams": [
{
"name": "deepseek",
"baseUrl": "https://api.deepseek.com",
"apiKey": "sk-你的DeepSeek密钥",
"models": [
"deepseek-v4-flash",
"deepseek-v4-pro"
]
},
{
"name": "qwen",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"apiKey": "sk-你的通义千问密钥",
"models": [
"qwen3.7-plus",
"qwen3.7-max"
]
},
{
"name": "kimi",
"baseUrl": "https://api.moonshot.cn/v1",
"apiKey": "sk-你的Kimi密钥",
"models": [
"kimi-k3",
"kimi-k3-flash"
]
},
{
"name": "glm",
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
"apiKey": "你的智谱GLM密钥",
"models": [
"glm-5.2",
"glm-5.2-flash"
]
}
]
}#!/usr/bin/env node
// ============================================================
// 宇宙核 AI 中继(自托管 · 零依赖 · Node 单文件)
// ============================================================
// 用途:把 DeepSeek / 通义 / Kimi 等各家 API Key 集中放在一台
// 闲置设备(旧手机 Termux / 电视盒子 / 旧电脑)上,
// 手机上的「宇宙核」App 只填一个中继地址 + 中继 Key,
// 即可使用所有上游模型——换模型不用再逐个改 Key。
//
// 功能:
// POST /v1/chat/completions 按模型路由到上游(流式 / 非流式透传)
// GET /v1/models 聚合所有上游的模型列表
// GET /health 健康检查
// 鉴权:Authorization: Bearer <relayKey>
// 丝滑增强:上游 429/5xx 指数退避重试、请求超时、stdout 日志
//
// 用法:node relay.mjs [config.json] 默认读取同目录 config.json
// 依赖:仅 Node.js 内置模块(http/https),无需 npm install
// ============================================================
import { createServer } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { request as httpRequest } from 'node:http';
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { networkInterfaces } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = process.argv[2] || join(ROOT, 'config.json');
// ---------- 配置 ----------
function loadConfig() {
let cfg;
try {
cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
} catch (e) {
console.error(`[中继] 无法读取配置 ${CONFIG_PATH}: ${e.message}`);
console.error('[中继] 请先复制 config.example.json 为 config.json 并填入你的 API Key');
process.exit(1);
}
if (!Array.isArray(cfg.upstreams) || cfg.upstreams.length === 0) {
console.error('[中继] 配置中缺少 upstreams 列表(至少一个上游服务商)');
process.exit(1);
}
if (!cfg.relayKey) {
console.warn('[中继] ⚠️ 未设置 relayKey,中继将不校验任何请求(任何人可用你的上游额度!)');
console.warn('[中继] 请在 config.json 中设置 relayKey 为一段长随机字符串');
}
return {
port: cfg.port || 8000,
host: cfg.host || '0.0.0.0', // 服务器部署可设 127.0.0.1 仅本机反代访问
relayKey: cfg.relayKey || '',
timeoutMs: (cfg.timeoutSeconds || 300) * 1000,
maxRetries: cfg.maxRetries ?? 2, // 429/5xx 重试次数
log: cfg.log !== false,
upstreams: cfg.upstreams.map((u) => ({
name: u.name || 'upstream',
baseUrl: (u.baseUrl || '').replace(/\/+$/, ''),
apiKey: u.apiKey || '',
models: Array.isArray(u.models) ? u.models : [],
})),
};
}
const cfg = loadConfig();
const BACKOFF_MS = [500, 1000, 2000, 4000];
const log = (msg) => { if (cfg.log) console.log(msg); };
// ---------- 模型路由 ----------
function pickUpstream(model) {
if (!model) return null;
const m = model.toLowerCase();
// 1. 精确匹配
for (const u of cfg.upstreams) {
if (u.models.some((x) => x.toLowerCase() === m)) return u;
}
// 2. 前缀匹配(deepseek-* → deepseek)
for (const u of cfg.upstreams) {
if (u.models.some((x) => m.startsWith(x.toLowerCase().replace('*', '')) && x.includes('*'))) return u;
}
// 3. 上游名前缀匹配(模型名以服务商名开头)
for (const u of cfg.upstreams) {
if (m.startsWith(u.name.toLowerCase() + '-') || m.startsWith(u.name.toLowerCase() + '/')) return u;
}
// 4. 兜底:单上游直接转发
if (cfg.upstreams.length === 1) return cfg.upstreams[0];
return null;
}
// ---------- 上游请求(带 429/5xx 退避重试) ----------
function upstreamRequest(upstream, path, headers, body) {
return new Promise((resolve, reject) => {
const url = new URL(upstream.baseUrl + path);
const doReq = url.protocol === 'https:' ? httpsRequest : httpRequest;
const req = doReq(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${upstream.apiKey}`,
...headers,
},
}, resolve);
req.setTimeout(cfg.timeoutMs, () => {
req.destroy(new Error('上游请求超时'));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function sendWithRetry(upstream, path, headers, body) {
let attempt = 0;
for (;;) {
const t0 = Date.now();
const response = await upstreamRequest(upstream, path, headers, body);
const code = response.statusCode || 0;
if ((code === 429 || code >= 500) && attempt < cfg.maxRetries) {
// 先排空响应体再重试(释放连接)
response.resume();
await new Promise((r) => response.on('end', r));
const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
log(` ↻ [${upstream.name}] HTTP ${code} 繁忙,${wait}ms 后重试(${attempt + 1}/${cfg.maxRetries})`);
await new Promise((r) => setTimeout(r, wait));
attempt++;
continue;
}
return { response, elapsedMs: Date.now() - t0, retries: attempt };
}
}
// ---------- 处理 /v1/chat/completions ----------
/** 兼容旧版 App 思考参数:thinking:{enabled:true} → thinking:{type:'enabled'}
* DeepSeek 新接口要求 type 字段,旧格式会被 400 拒绝;返回是否发生了转换 */
function normalizeThinking(payload) {
const t = payload && payload.thinking;
if (t && typeof t === 'object' && t.type === undefined && typeof t.enabled === 'boolean') {
payload.thinking = { ...t, type: t.enabled ? 'enabled' : 'disabled' };
return true;
}
return false;
}
async function handleChat(req, res, authBody) {
// 解析请求体
const raw = await readBody(req);
let payload;
try {
payload = JSON.parse(raw);
} catch {
return sendJson(res, 400, { error: { message: '请求体不是合法 JSON' } });
}
const model = typeof payload.model === 'string' ? payload.model : '';
const upstream = pickUpstream(model);
if (!upstream) {
const available = cfg.upstreams.flatMap((u) => u.models).join('、') || '(未配置模型列表)';
return sendJson(res, 400, {
error: { message: `没有找到能处理模型 "${model}" 的上游。可用模型:${available}` },
});
}
const isStream = payload.stream === true;
// 兼容旧版 App:thinking 参数格式转换后重序列化再转发
const bodyForUpstream = normalizeThinking(payload) ? JSON.stringify(payload) : raw;
const t0 = Date.now();
try {
const { response, elapsedMs, retries } = await sendWithRetry(
upstream, '/chat/completions',
{ Accept: 'text/event-stream' }, bodyForUpstream, // 原样透传 body(含 stream/model/messages)
);
const code = response.statusCode || 502;
res.writeHead(code, {
'Content-Type': response.headers['content-type'] || 'application/json',
'Cache-Control': 'no-cache',
'X-Relay-Upstream': upstream.name,
});
if (code === 200 && isStream) {
// 流式透传:把上游 SSE 原样转发(含 keep-alive 空行)
response.pipe(res);
response.on('end', () => {
log(`[${new Date().toLocaleTimeString()}] ${model} → ${upstream.name} 200 流式 ${Date.now() - t0}ms${retries ? `(重试${retries}次)` : ''}`);
res.end();
});
} else {
// 非流式 / 错误响应:读完再回
const chunks = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => {
const body = Buffer.concat(chunks);
log(`[${new Date().toLocaleTimeString()}] ${model} → ${upstream.name} ${code} ${Date.now() - t0}ms${retries ? `(重试${retries}次)` : ''}`);
res.end(body);
});
response.on('error', () => res.end());
}
} catch (e) {
log(`[${new Date().toLocaleTimeString()}] ${model} → ${upstream.name} 失败: ${e.message}`);
sendJson(res, 502, { error: { message: `中继转发失败:${e.message}` } });
}
}
// ---------- 处理 /v1/models ----------
function handleModels(res) {
const data = cfg.upstreams.flatMap((u) =>
u.models.map((id) => ({
id,
object: 'model',
owned_by: u.name,
// 中继标记:宇宙核 App 列表里可识别
relay: u.name,
})),
);
sendJson(res, 200, { object: 'list', data });
}
// ---------- 工具 ----------
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function sendJson(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(body);
}
function checkAuth(req, res) {
if (!cfg.relayKey) return true; // 未配置 Key = 开放(配置时警告过)
const header = req.headers['authorization'] || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (token === cfg.relayKey) return true;
sendJson(res, 401, { error: { message: '中继 Key 无效(Authorization: Bearer <你的中继Key>)' } });
return false;
}
// ---------- 服务器 ----------
const server = createServer((req, res) => {
const path = (req.url || '').split('?')[0];
// 全量请求日志(含鉴权失败),便于远程排查 App 端问题
log(`[请求] ${req.method} ${path} ← ${req.headers['x-forwarded-for'] || req.socket.remoteAddress || '?'}`);
// 容错:BaseURL 少填 /v1 时自动兼容(/models 等同 /v1/models)
const p = !path.startsWith('/v1') &&
(path.startsWith('/models') || path.startsWith('/chat/completions') || path.startsWith('/tasks'))
? '/v1' + path
: path;
if (req.method === 'GET' && path === '/health') {
return sendJson(res, 200, { status: 'ok', name: 'yuzhouhe-ai-relay', time: Date.now() });
}
if (!checkAuth(req, res)) return;
if (req.method === 'GET' && p === '/v1/models') return handleModels(res);
if (req.method === 'POST' && p === '/v1/chat/completions') {
return handleChat(req, res).catch((e) => sendJson(res, 500, { error: { message: e.message } }));
}
// ============ 任务代理(中继 2.0):中继持有上游 SSE,App 轮询拿结果 ============
// 解决手机 App 直接握 SSE 长连接、切后台被系统断流的问题(元宝式体验)
if (req.method === 'POST' && p === '/v1/tasks') {
return handleTaskCreate(req, res);
}
const taskMatch = p.match(/^\/v1\/tasks\/([A-Za-z0-9-]+)$/);
if (req.method === 'GET' && taskMatch) {
const task = tasks.get(taskMatch[1]);
if (!task) return sendJson(res, 404, { error: { message: '任务不存在(中继重启后任务丢失)' } });
return sendJson(res, 200, {
taskId: task.id,
status: task.status,
reasoning: task.reasoning,
content: task.content,
error: task.error || null,
});
}
if (req.method === 'GET' && path === '/') {
return sendJson(res, 200, {
name: 'yuzhouhe-ai-relay',
version: '2.1.0',
endpoints: [
'GET /health',
'GET /v1/models',
'POST /v1/chat/completions',
'POST /v1/tasks',
'GET /v1/tasks/{id}',
],
tip: '在宇宙核 App 的 AI 设置里,把 BaseURL 填为 http://<本机IP>:端口/v1,API Key 填为中继 Key',
});
}
sendJson(res, 404, { error: { message: 'Not Found' } });
});
// ============ 任务代理实现 ============
/** 任务存储:内存 Map(中继重启丢失,README 有说明);上限防内存膨胀 */
const tasks = new Map();
const MAX_TASKS = Number(process.env.RELAY_MAX_TASKS || 100);
async function handleTaskCreate(req, res) {
let payload;
try {
const raw = await readBody(req);
payload = JSON.parse(raw);
} catch {
return sendJson(res, 400, { error: { message: '请求体不是合法 JSON' } });
}
const model = typeof payload.model === 'string' ? payload.model : '';
const upstream = pickUpstream(model);
if (!upstream) {
return sendJson(res, 400, { error: { message: `没有找到能处理模型 "${model}" 的上游` } });
}
if (tasks.size >= MAX_TASKS) {
return sendJson(res, 429, { error: { message: `任务数已达上限(${MAX_TASKS}),请稍后再试` } });
}
const task = {
id: randomUUID(),
status: 'running', // running | done | error
reasoning: '',
content: '',
error: null,
model,
upstream: upstream.name,
createdAt: Date.now(),
aborted: false,
};
tasks.set(task.id, task);
log(`[${new Date().toLocaleTimeString()}] [任务] ${task.id} 创建 ${model} → ${upstream.name}`);
// 后台执行:中继持有上游 SSE(App 切后台/断网不影响),增量存储,App 轮询拉取
normalizeThinking(payload); // 兼容旧版 App 思考参数格式
runTask(task, upstream, JSON.stringify(payload)).catch((e) => {
task.status = 'error';
task.error = e.message;
log(`[任务] ${task.id} 失败: ${e.message}`);
});
sendJson(res, 202, { taskId: task.id, status: 'running' });
}
async function runTask(task, upstream, body) {
const t0 = Date.now();
const { response } = await sendWithRetry(upstream, '/chat/completions', {}, body);
const code = response.statusCode || 502;
if (code !== 200) {
const err = await readBody(response);
task.status = 'error';
task.error = `上游错误 ${code}: ${err.slice(0, 200)}`;
return;
}
// 解析 SSE(兼容非流式 JSON 响应)
const ct = (response.headers['content-type'] || '').toLowerCase();
if (!ct.includes('text/event-stream')) {
const raw = await readBody(response);
try {
const j = JSON.parse(raw);
task.content = j?.choices?.[0]?.message?.content ?? '';
task.reasoning = j?.choices?.[0]?.message?.reasoning_content ?? '';
} catch {
task.error = '上游返回非 JSON';
task.status = 'error';
return;
}
task.status = 'done';
log(`[任务] ${task.id} 完成(非流式) ${Date.now() - t0}ms`);
return;
}
// 流式:逐行解析增量(含 reasoning_content / content / finish_reason)
response.setEncoding('utf8');
let buf = '';
response.on('data', (c) => {
buf += c;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line.startsWith('data: ')) continue; // 忽略空行 / : keep-alive
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const j = JSON.parse(data);
const choice = j?.choices?.[0];
if (!choice) continue;
if (typeof choice.finish_reason === 'string') {
// 记录结束原因(任务结束条件由 [DONE] 或流结束触发,此处仅记录)
task.finishReason = choice.finish_reason;
}
const delta = choice.delta || {};
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
task.reasoning += delta.reasoning_content;
}
if (typeof delta.content === 'string' && delta.content) {
task.content += delta.content;
}
} catch { /* 非法行忽略 */ }
}
});
response.on('end', () => {
if (task.status !== 'error') {
task.status = 'done';
log(`[任务] ${task.id} 完成 ${Date.now() - t0}ms,${task.content.length} 字符`);
}
});
response.on('error', (e) => {
task.status = 'error';
task.error = `上游流中断: ${e.message}`;
});
}
server.listen(cfg.port, cfg.host, () => {
const upstreams = cfg.upstreams.map((u) => `${u.name}(${u.models.length}模型)`).join(',');
console.log('========================================');
console.log(' 宇宙核 AI 中继已启动');
console.log(` 地址: http://${cfg.host || '0.0.0.0'}:${cfg.port}`);
console.log(` 上游: ${upstreams}`);
console.log(` 中继Key: ${cfg.relayKey ? cfg.relayKey.slice(0, 4) + '****' : '(未设置!开放模式)'}`);
console.log(' 手机 App 配置: BaseURL = http://<本机IP>:' + cfg.port + '/v1');
console.log(' 局域网 IP 查看: 同网段手机浏览器访问 http://' + lanIp() + ':' + cfg.port + '/health');
console.log('========================================');
});
// 尽力探测局域网 IP(打印提示用)
function lanIp() {
try {
for (const list of Object.values(networkInterfaces())) {
for (const it of list || []) {
if (it.family === 'IPv4' && !it.internal && it.address.startsWith('192.168.')) return it.address;
}
}
} catch {}
return '<本机IP>';
}
Por defecto el relay solo funciona en tu Wi-Fi doméstico. Para usarlo fuera necesitas un túnel cifrado que conecte tu relay con internet. Dos opciones — nada va integrado en la app; tú eliges:
Usa tu propio servidor en la nube (panel BT / Linux, 2 núcleos 2 GB es suficiente) como túnel: el dispositivo relay se conecta de salida al servidor y tu móvil accede a https://tu-dominio/v1. Cifrado de extremo a extremo; el servidor no guarda claves — aunque lo hackeen, los atacantes no obtienen nada.
#!/bin/bash
# ============================================================
# 宇宙核 AI 中继 · 服务器端 frps 部署脚本(AI 可读可执行版)
# ============================================================
# 【这是什么】
# 在用户的云服务器(宝塔/Linux)上部署 frp 服务端(frps),
# 为中继设备(旧手机/盒子)提供外网隧道入口。
#
# 【给 AI 的说明】
# 把本脚本和官网教程一起给 AI,AI 可以:
# 1. 在服务器上执行本脚本(需要 root)
# 2. 按脚本输出的提示帮用户完成:安全组放行、DNS 解析
# 3. 然后用 install-frpc.sh 在用户中继设备上建立隧道
#
# 【前置条件】
# - root 权限
# - 一个已解析到本服务器的域名(默认 relay.yuzhouhe.com,可改)
# - 出网可访问 GitHub(下载 frp 二进制)
#
# 【安全性设计】(防拖库/防滥用)
# - frps 无状态纯转发:不存任何 Key 和用户数据,被攻破也拿不到东西
# - frpc<->frps 全程 TLS 加密(transport.tls.force)
# - token 鉴权(随机生成,拒绝未授权隧道)
# - 管理面板只监听 127.0.0.1(杜绝公网爆破)
# - nginx 反代层:https + 限速(10r/s)+ 请求体 1MB 上限
# ============================================================
set -e
DOMAIN="${1:-relay.yuzhouhe.com}" # 访问域名(需已解析到本服务器)
EMAIL="${2:-admin@${DOMAIN#relay.}}" # 证书邮箱(可改)
FRP_VER="0.70.1"
echo "========================================"
echo " 宇宙核 AI 中继 · frps 部署"
echo " 域名: $DOMAIN"
echo "========================================"
# ---------- 1. 检测系统 ----------
if [ "$(id -u)" != "0" ]; then echo "❌ 需要 root 权限(sudo -i 后重试)"; exit 1; fi
if ! command -v nginx >/dev/null 2>&1; then
echo "⚠️ 未检测到 nginx(宝塔环境自带)。请先在宝塔面板安装 Nginx 再运行本脚本。"
exit 1
fi
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) FRP_ARCH="amd64" ;;
aarch64|arm64) FRP_ARCH="arm64" ;;
*) echo "❌ 不支持的架构: $ARCH"; exit 1 ;;
esac
# ---------- 2. 下载并安装 frps ----------
echo "[1/4] 下载 frp v${FRP_VER} (${FRP_ARCH}) ..."
mkdir -p /opt/frp
cd /tmp
curl -fsSL -o frp.tar.gz "https://github.com/fatedier/frp/releases/download/v${FRP_VER}/frp_${FRP_VER}_linux_${FRP_ARCH}.tar.gz"
tar -xzf frp.tar.gz
cp "frp_${FRP_VER}_linux_${FRP_ARCH}/frps" /opt/frp/frps
chmod +x /opt/frp/frps
rm -rf "frp_${FRP_VER}_linux_${FRP_ARCH}" frp.tar.gz
# ---------- 3. 配置(token 自动随机生成) ----------
echo "[2/4] 生成配置(token 随机)..."
TOKEN=$(openssl rand -hex 32)
DASH_PASS=$(openssl rand -hex 12)
cat > /opt/frp/frps.toml <<EOF
# 宇宙核 AI 中继 frps 配置
bindPort = 7000 # 中继设备(frpc)出站连入端口
vhostHTTPPort = 7080 # HTTP vhost 端口(本机 nginx 反代目标)
auth.method = "token"
auth.token = "$TOKEN"
webServer.addr = "127.0.0.1" # 管理面板仅本机
webServer.port = 7500
webServer.user = "yzh"
webServer.password = "$DASH_PASS"
transport.tls.force = true # frpc<->frps 全程 TLS
log.to = "/opt/frp/frps.log"
log.level = "info"
log.maxDays = 7
EOF
# ---------- 4. systemd 自启 ----------
echo "[3/4] 注册 systemd 服务..."
cat > /etc/systemd/system/frps.service <<EOF
[Unit]
Description=YuzhouHe frps (AI relay tunnel)
After=network.target
[Service]
ExecStart=/opt/frp/frps -c /opt/frp/frps.toml
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable frps >/dev/null 2>&1
systemctl start frps
sleep 1
systemctl is-active frps >/dev/null && echo " frps 运行中 ✓" || { echo " ❌ frps 启动失败,查看 journalctl -u frps"; exit 1; }
# ---------- 5. nginx 反代 + HTTPS ----------
echo "[4/4] 配置 nginx 反代 + HTTPS ..."
# 找到 nginx 配置目录(宝塔路径优先)
if [ -d /www/server/panel/vhost/nginx ]; then
VHOST_DIR="/www/server/panel/vhost/nginx"
elif [ -d /etc/nginx/conf.d ]; then
VHOST_DIR="/etc/nginx/conf.d"
else
VHOST_DIR="/etc/nginx"
fi
# 限速 zone(防刷)
if ! grep -q 'zone=relay' /www/server/nginx/conf/nginx.conf 2>/dev/null && [ -f /www/server/nginx/conf/nginx.conf ]; then
sed -i 's|^http {|http {\n limit_req_zone $binary_remote_addr zone=relay:10m rate=10r/s;|' /www/server/nginx/conf/nginx.conf
fi
# acme.sh 证书(无则安装)
if [ ! -f /root/.acme.sh/acme.sh ]; then
curl -s https://get.acme.sh | sh -s email="$EMAIL"
fi
mkdir -p /www/wwwroot/relay-ssl
/root/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot /www/wwwroot/relay-ssl --keylength ec-256 --force 2>/dev/null || true
cat > "$VHOST_DIR/$DOMAIN.conf" <<EOF
# 宇宙核 AI 中继反代(自动生成)
server {
listen 80;
server_name $DOMAIN;
location ^~ /.well-known/acme-challenge/ { root /www/wwwroot/relay-ssl; }
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl;
server_name $DOMAIN;
ssl_certificate /root/.acme.sh/${DOMAIN}_ecc/fullchain.cer;
ssl_certificate_key /root/.acme.sh/${DOMAIN}_ecc/${DOMAIN}.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:7080;
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_buffering off; # SSE 流式必须关缓冲
proxy_read_timeout 600s;
proxy_send_timeout 600s;
client_max_body_size 1m;
limit_req zone=relay burst=20 nodelay;
}
}
EOF
NGINX_BIN=$(command -v nginx || echo /www/server/nginx/sbin/nginx)
$NGINX_BIN -t && $NGINX_BIN -s reload
echo ""
echo "========================================"
echo "✅ frps 部署完成"
echo "========================================"
echo " 域名: https://$DOMAIN"
echo " frp 端口: 7000(需在云安全组/防火墙放行 TCP 7000)"
echo " Token: $TOKEN"
echo " 面板: http://127.0.0.1:7500 (仅本机) 用户 yzh 密码 $DASH_PASS"
echo ""
echo "【接下来(需要用户/协作 AI 完成)】"
echo " 1. 云控制台安全组放行 TCP 7000(frpc 连入)"
echo " 2. DNS 解析 $DOMAIN → 本服务器 IP(若未解析)"
echo " 3. 在用户中继设备(旧手机 Termux)运行 install-frpc.sh"
echo " - 服务器地址/Token 填上面的值"
echo " - 访问域名填 $DOMAIN"
echo " 4. 完成后面向公网的地址是 https://$DOMAIN/v1"
echo "========================================"
#!/data/data/com.termux/files/usr/bin/bash
# ============================================================
# 宇宙核 AI 中继 · frpc 外网通道一键配置(Termux)
# 作用:中继设备(旧手机/盒子)通过 frp 隧道连到你自己的服务器,
# 出门在外用手机访问 http://relay.yuzhouhe.com 即可连上家中继。
# 前置:1) 服务器已部署 frps(见官网教程「服务器端部署」)
# 2) 已在服务器安全组放行 7000 端口
# 用法:bash install-frpc.sh
# ============================================================
set -e
echo "========================================"
echo " 宇宙核 AI 中继 · 外网通道(frpc)"
echo "========================================"
# ---------- 1. 收集参数 ----------
read -rp "frps 服务器地址(IP 或域名,如 47.82.104.224): " SERVER
[ -z "$SERVER" ] && echo "服务器地址不能为空" && exit 1
read -rp "frps 端口(默认 7000): " PORT
PORT="${PORT:-7000}"
read -rp "frps Token(部署 frps 时生成,在服务器 /opt/frp/frps.toml 里): " TOKEN
[ -z "$TOKEN" ] && echo "Token 不能为空" && exit 1
read -rp "访问域名(如 relay.yuzhouhe.com,需已解析到服务器): " DOMAIN
[ -z "$DOMAIN" ] && echo "域名不能为空" && exit 1
read -rp "中继本地端口(默认 8000,与 config.json 的 port 一致): " LOCAL_PORT
LOCAL_PORT="${LOCAL_PORT:-8000}"
# ---------- 2. 安装 frpc ----------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FRPC="$SCRIPT_DIR/frpc"
if [ ! -f "$FRPC" ]; then
echo ""
echo "[1/3] 下载 frpc(Termux arm64)..."
ARCH=$(uname -m)
case "$ARCH" in
aarch64|arm64) FRP_ARCH="arm64" ;;
*) echo "⚠️ 不支持的架构: $ARCH(本脚本仅支持 arm64)"; exit 1 ;;
esac
# 官网教程提供下载地址模板;此处尝试官方 GitHub(失败可手动下载放同目录)
FRP_VER="0.70.1"
curl -fsSL -o "$SCRIPT_DIR/frp.tar.gz" \
"https://github.com/fatedier/frp/releases/download/v${FRP_VER}/frp_${FRP_VER}_linux_${FRP_ARCH}.tar.gz" \
|| { echo "⚠️ 自动下载失败:请从 frp 官方 GitHub 下载 frp_${FRP_VER}_linux_${FRP_ARCH}.tar.gz,"
echo " 解压后把 frpc 文件放到本目录再运行本脚本"; exit 1; }
tar -xzf "$SCRIPT_DIR/frp.tar.gz"
cp "frp_${FRP_VER}_linux_${FRP_ARCH}/frpc" "$FRPC"
chmod +x "$FRPC"
rm -rf "$SCRIPT_DIR/frp.tar.gz" "frp_${FRP_VER}_linux_${FRP_ARCH}"
echo " frpc 已安装"
else
echo "[1/3] frpc 已存在"
fi
# ---------- 3. 生成 frpc 配置 ----------
echo ""
echo "[2/3] 生成 frpc.toml ..."
cat > "$SCRIPT_DIR/frpc.toml" <<EOF
# 宇宙核 AI 中继 frpc 配置(由 install-frpc.sh 生成)
serverAddr = "$SERVER"
serverPort = $PORT
auth.token = "$TOKEN"
[[proxies]]
name = "relay"
type = "http"
localIP = "127.0.0.1"
localPort = $LOCAL_PORT
customDomains = ["$DOMAIN"]
EOF
# ---------- 4. 启动 ----------
echo ""
echo "[3/3] 启动 frpc ..."
termux-wake-lock 2>/dev/null || true
nohup "$FRPC" -c "$SCRIPT_DIR/frpc.toml" > "$SCRIPT_DIR/frpc.log" 2>&1 &
sleep 2
if pgrep -f "frpc -c" >/dev/null 2>&1; then
echo ""
echo "✅ frpc 已启动!日志: $SCRIPT_DIR/frpc.log"
echo " 出门在外时,中继地址填: http://$DOMAIN/v1"
else
echo "❌ frpc 启动失败,请查看 $SCRIPT_DIR/frpc.log"
exit 1
fi
echo ""
echo "========================================"
echo " 在「宇宙核」App 里这样配置:"
echo " AI 设置 → 服务商 → 中继"
echo " BaseURL:http://$DOMAIN/v1"
echo " API Key:你的中继 Key(config.json 里的 relayKey)"
echo " 局域网内用 http://<中继IP>:8000/v1 也可以,两者并存"
echo "========================================"
Sí. Usa frp + tu propio servidor (recomendado, ver sección «Usarlo fuera de casa») o Tailscale. Con el túnel activo, pon la BaseURL de la app en la dirección pública.
frps es un reenviador puro que no guarda claves; el túnel va totalmente cifrado con TLS y autenticación por token; el panel nunca se expone; la capa Nginx limita la velocidad. Aunque hackeen el servidor, los atacantes no consiguen ninguna de tus claves API.
No. Solo reenvía peticiones y nunca ejecuta modelos — el uso de memoria suele ser de unas pocas decenas de MB, nada para un móvil viejo; en reposo casi no gasta batería.
El relay en Termux no arranca automáticamente al encender. Tras un reinicio, ejecuta bash install-termux.sh otra vez — el script detecta la configuración existente y arranca directamente.
Sí, pero no recomendamos exponer el puerto directamente a internet. Usa Tailscale para meter el móvil y el dispositivo relay en la misma red virtual — accesible desde cualquier red y totalmente cifrado.
Las claves se guardan solo en tu dispositivo relay; el móvil guarda la clave del relay, no las de cada proveedor. Cambia relayKey en config.json a una cadena aleatoria larga y desactiva el reenvío de puertos del puerto del relay en el router.
Cualquier proveedor compatible con OpenAI: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI, Gemini (endpoint compatible con OpenAI), Claude (vía OpenRouter), etc. Añádelos uno a uno en la lista upstreams de config.json; el enrutamiento admite coincidencia exacta y por prefijo.
Copia config.json (contiene tus claves de proveedores) del directorio del relay al nuevo dispositivo y ejecuta relay.mjs o el script de un clic. En el móvil, solo cambia la IP de la BaseURL por la del nuevo dispositivo.