各社のAPIキーを1台のアイドル端末に集約し、スマホでは1つのアドレスを入力するだけ——AIチャット・AIプログラミング・AIプラグイン生成がすべてスムーズに接続。
CosmosBoxのAIツール(AIチャット・AIプログラミング・AIプラグイン生成)はBYOK方式——各LLMプロバイダーのAPIキーを自分で用意します。複数のプロバイダーに直接接続すると、プロバイダーごとにキーとBaseURLが必要になり、モデルを変えるたびに設定を切り替えることになります。
自前中継があれば、これがすべて1つのアドレスに集約されます:各社のキーは中継端末で一度だけ設定し、スマホには中継アドレス+中継キーを1つ入力するだけ。モデル名は自動的に該当プロバイダーへルーティングされます。また、プロバイダーが混雑しているときは指数バックオフで自動リトライするため、弱いネットワークでも安定します。
| 項目 | 複数直結 | 中継利用 |
|---|---|---|
| アプリ設定 | プロバイダーごとにキーとアドレス | アドレス1つ+キー1つだけ |
| モデル切替 | 設定を手動で切替 | モデル名で自動ルーティング |
| 混雑時(429) | 即失敗・手動リトライ | 指数バックオフで自動リトライ |
| キー管理 | 端末ごとに散在 | 中継端末だけに保存 |
中継は、自分の端末で動く超軽量のOpenAI互換転送サービスです。リクエストはまず中継に届き、モデル名から該当プロバイダーを見つけて転送し、結果をストリーミングでスマホに返します。サービス全体は依存ゼロのNode.js単一ファイル——サードパーティのパッケージもデータベースも不要です。
下のinstall-termux.shを丸ごとコピーし(またはAI中継プロジェクトから取得)、Termuxで実行するだけ:スクリプトがNode.jsをインストールし、対話式ウィザードで設定を生成(各社キーを1つずつ入力)、中継を起動してスマホ側の設定情報を表示します。
#!/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 "========================================"
ワンクリックスクリプトを使わず、PC / NASで動かしたい場合は手動3ステップ。下の2つのコードブロックが中継プログラム本体と設定例です。コピーして保存し、手順に従ってください。
{
"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>';
}
中継は初期状態では自宅のWi-Fi内でのみ利用できます。外出先で使うには、自宅の中継をインターネットに接続する暗号化トンネルが必要です。2つの選択肢があります——アプリには組み込まれていません。お好みで選んでください:
自分のクラウドサーバー(BTパネル / Linux、2コア2GBで十分)をトンネルとして使います:中継デバイスはサーバーへ発信接続し、スマホは https://ドメイン/v1 にアクセス。エンドツーエンド暗号化され、サーバーは鍵を一切保存しません——サーバーが突破されても攻撃者は何も得られません。
#!/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 "========================================"
使えます。frp + 自分のサーバー(推奨。「外出先での利用」の節を参照)またはTailscaleを利用します。トンネル確立後、アプリのBaseURLを公開アドレスに設定してください。
frpsはキーを保存しない純転送で、トンネルはToken認証付きの完全TLS暗号化、パネルは公開されず、Nginx層がレート制限します。サーバーが突破されても、攻撃者はあなたのAPIキーを一切得られません。
いいえ。転送するだけでモデルは実行しません——メモリ使用量は通常数十MB程度で、古いスマホでも問題ありません。アイドル時はバッテリーをほとんど消費しません。
Termuxの中継は起動時に自動開始しません。再起動後は bash install-termux.sh をもう一度実行してください(設定は保存済みなので直接起動します)。
使えますが、ポートを直接公開ネットワークに晒すのはお勧めしません。Tailscaleでスマホと中継端末を同じ仮想ネットワークに入れれば、どのネットワークからでも暗号化されたままアクセスできます。
キーは自分の端末にだけ保存され、スマホが持つのは中継キーであり各社キーではありません。config.jsonのrelayKeyは必ず長いランダム文字列に変更し、ルーターで中継ポートのポート開放を無効にしてください。
OpenAI互換プロトコルのプロバイダーならすべて対応:DeepSeek・通義千問・Kimi・智譜GLM・OpenAI・Gemini(OpenAI互換エンドポイント)・Claude(OpenRouter経由)など。config.jsonのupstreamsに1つずつ追加してください。ルーティングは完全一致と前方一致の両方に対応しています。
中継ディレクトリのconfig.json(各社キーを含む)を新しい端末にコピーし、relay.mjsまたはワンクリックスクリプトを実行します。スマホ側はBaseURLのIPを新しい端末のIPに変更するだけです。