Alle API-Keys auf einem ungenutzten Gerät bündeln, am Handy nur eine Adresse eintragen — KI-Chat, KI-Programmierung und KI-Plugin-Generierung, alles flüssig verbunden.
Die KI-Tools von CosmosBox (KI-Chat, KI-Programmierung, KI-Plugin-Generierung) arbeiten im BYOK-Modus — du bringst deine eigenen API-Keys für jeden Anbieter mit. Bei direkter Verbindung mit mehreren Anbietern braucht jeder Anbieter einen eigenen Key und eine eigene BaseURL, und beim Modellwechsel musst du die Konfiguration umstellen.
Ein selbst gehostetes Relay bündelt das alles in einer einzigen Adresse: Die Keys aller Anbieter werden einmal auf dem Relay-Gerät konfiguriert, am Handy trägst du nur eine Relay-Adresse + einen Relay-Key ein. Modellnamen werden automatisch zum passenden Anbieter geroutet. Bei überlasteten Diensten versucht das Relay außerdem automatisch mit exponentiellem Backoff erneut — stabiler auch bei schwachem Netz.
| Aspekt | Direkte Anbindung | Mit Relay |
|---|---|---|
| App-Konfiguration | Ein Key und eine Adresse pro Anbieter | Nur eine Adresse + ein Key |
| Modellwechsel | Anbieter-Konfiguration manuell umstellen | Modellname wird automatisch geroutet |
| Überlasteter Dienst (429) | Sofortiger Fehler, manuell erneut versuchen | Automatischer exponentieller Backoff |
| Key-Verwaltung | Über Geräte verstreut | Nur auf dem Relay-Gerät gespeichert |
Das Relay ist ein sehr leichtgewichtiger OpenAI-kompatibler Weiterleitungsdienst, der auf deinem eigenen Gerät läuft. Deine Anfrage geht zuerst zum Relay; das Relay findet anhand des Modellnamens den passenden Anbieter, leitet weiter und streamt das Ergebnis zurück zum Handy. Der ganze Dienst ist eine einzige Node.js-Datei ohne Abhängigkeiten — keine Drittanbieter-Pakete, keine Datenbank.
Kopiere das untenstehende install-termux.sh vollständig (oder hole es aus dem AI-Relay-Projekt) und führe es in Termux aus: Das Skript installiert Node.js, erzeugt die Konfiguration per Assistent (Keys der Anbieter einzeln eingeben), startet das Relay und gibt die Einstellungen für dein Handy aus.
#!/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 "========================================"
Kein Ein-Klick-Skript gewünscht oder auf Computer / NAS betreiben? Drei manuelle Schritte. Die beiden Codeblöcke unten sind das vollständige Relay-Programm und ein Konfigurationsbeispiel — kopieren, speichern und den Schritten folgen.
{
"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>';
}
Standardmäßig funktioniert das Relay nur im heimischen WLAN. Für die Nutzung unterwegs brauchst du einen verschlüsselten Tunnel zum öffentlichen Internet. Zwei Optionen — nichts ist in die App eingebaut; du entscheidest:
Nutze deinen eigenen Cloud-Server (BT-Panel / Linux, 2 Kerne 2 GB reichen) als Tunnel: Das Relay-Gerät verbindet sich ausgehend zum Server, dein Handy greift auf https://deine-domain/v1 zu. Ende-zu-Ende verschlüsselt; der Server speichert keine Schlüssel — selbst ein geknackter Server bringt Angreifern nichts.
#!/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 "========================================"
Ja. Nutze frp + eigenen Server (empfohlen, siehe Abschnitt „Unterwegs nutzen“) oder Tailscale. Sobald der Tunnel steht, BaseURL der App auf die öffentliche Adresse setzen.
frps ist ein reiner Weiterleiter ohne Schlüsselspeicher; der Tunnel ist vollständig TLS-verschlüsselt mit Token-Auth; das Admin-Panel ist nie öffentlich; die Nginx-Ebene begrenzt die Rate. Selbst bei einem geknackten Server bekommen Angreifer keinen deiner API-Schlüssel.
Nein. Es leitet nur Anfragen weiter und führt keine Modelle aus — der Speicherbedarf liegt meist bei einigen Dutzend MB, für ein altes Handy kein Problem; im Leerlauf fast kein Akkuverbrauch.
Das Relay in Termux startet nicht automatisch beim Booten. Nach einem Neustart einfach bash install-termux.sh erneut ausführen — das Skript erkennt die vorhandene Konfiguration und startet direkt.
Ja, aber den Port direkt ins öffentliche Internet zu legen wird nicht empfohlen. Nutze Tailscale, um Handy und Relay-Gerät in dasselbe virtuelle Netzwerk zu bringen — von jedem Netz erreichbar und vollständig verschlüsselt.
Die Keys werden nur auf deinem eigenen Relay-Gerät gespeichert; das Handy hält den Relay-Key statt der Anbieter-Keys. Ändere unbedingt relayKey in config.json auf eine lange Zufallszeichenkette und deaktiviere das Port-Forwarding für den Relay-Port im Router.
Jeder OpenAI-kompatible Anbieter: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI, Gemini (OpenAI-kompatibler Endpunkt), Claude (über OpenRouter) und mehr. In der upstreams-Liste von config.json einzeln hinzufügen; das Routing unterstützt exakte Übereinstimmung und Präfixübereinstimmung.
config.json (enthält deine Anbieter-Keys) aus dem Relay-Verzeichnis auf das neue Gerät kopieren, dann relay.mjs oder das Ein-Klick-Skript ausführen. Am Handy nur die IP in der BaseURL auf die IP des neuen Geräts ändern.