// AI RELAY

Relay de IA próprio

Concentre todas as chaves API num dispositivo inativo e escreva apenas um endereço no telemóvel — chat IA, programação IA e geração de plugins IA, tudo ligado sem fricção.

Porque precisa de um relay?

As ferramentas de IA do CosmosBox (chat IA, programação IA, geração de plugins IA) funcionam em modo BYOK — traz as suas próprias chaves API de cada fornecedor. Ligar diretamente a vários fornecedores significa uma chave e uma BaseURL por fornecedor, e mudar de configuração sempre que troca de modelo.

Um relay próprio condensa tudo num único endereço: as chaves de cada fornecedor configuram-se uma vez no dispositivo relay, e no telemóvel só preenche um endereço + uma chave de relay. Os nomes dos modelos são encaminhados automaticamente para o fornecedor certo. O relay também tenta novamente com backoff exponencial quando um fornecedor está ocupado, tornando a experiência mais estável em redes fracas.

Ligação direta múltipla vs. relay

AspectoDireta a vários fornecedoresCom relay
Configuração na appUma chave e um endereço por fornecedorSó um endereço + uma chave
Trocar de modeloMudar configuração manualmenteNome do modelo encaminhado sozinho
Fornecedor ocupado (429)Falha imediata, tentar manualmenteNova tentativa automática com backoff exponencial
Gestão de chavesEspalhadas por dispositivosSó no dispositivo relay

Como funciona

O relay é um serviço de reencaminhamento muito leve e compatível com OpenAI que corre no seu próprio dispositivo. O seu pedido vai primeiro para o relay; ele encontra o fornecedor pelo nome do modelo, reencaminha e devolve o resultado em streaming para o telemóvel. Todo o serviço é um único ficheiro Node.js sem dependências — sem pacotes de terceiros, sem base de dados.

  1. 1A app CosmosBox no telemóvel envia o pedido → endereço do relay
  2. 2O relay associa o nome do modelo a um fornecedor (ex.: deepseek-v4-flash → DeepSeek)
  3. 3O fornecedor gera o conteúdo e transmite-o palavra a palavra de volta
  4. 4O relay reencaminha tal como está; a app mostra em tempo real

Preparação

  • Um dispositivo inativo: telemóvel Android antigo, box TV Android ou PC velho (não executa modelos, consumo mínimo de recursos)
  • Telemóvel antigo / box TV: instalar Termux (as box TV precisam de APK em sideload); PC velho: instalar Node.js 18 ou superior
  • Pelo menos uma chave API de LLM: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI… qualquer fornecedor compatível com OpenAI funciona

Implementação num clique (telemóvel antigo / box TV · Termux)

Copie o script install-termux.sh abaixo na íntegra (ou obtenha-o do projeto relay de IA) e execute-o no Termux: o script instala o Node.js, gera a configuração com um assistente interativo (introduz as chaves de cada fornecedor uma a uma), inicia o relay e imprime as definições para o seu telemóvel.

install-termux.sh (script de um clique)
#!/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 "========================================"
  1. 1No Termux execute: pkg update -y && pkg install -y nodejs (só na primeira vez)
  2. 2Execute bash install-termux.sh
  3. 3Siga o assistente: porta do relay (8000 por predefinição), chave do relay (16+ caracteres aleatórios recomendado) e depois BaseURL / chave API / lista de modelos de cada fornecedor
  4. 4Quando iniciar, o script imprime as definições para o telemóvel — basta preenchê-las na app
💡
Escrever comandos com o comando da TV é penoso: ligue um teclado USB ou entre por SSH na LAN. Após reiniciar o telemóvel, o relay não arranca sozinho — volte a executar bash install-termux.sh (a configuração está guardada e arrancará diretamente).

Implementação manual (qualquer dispositivo · Node.js)

Não quer o script de um clique, ou prefere um PC / NAS? Três passos manuais. Os dois blocos de código abaixo são o programa completo do relay e um exemplo de configuração — copie, guarde e siga os passos.

  1. 1Instale Node.js 18+ (confirme com node -v)
  2. 2Guarde relay.mjs em qualquer diretório; copie config.example.json para config.json e preencha as suas chaves API
  3. 3Execute node relay.mjs config.json
  4. 4O terminal imprime as informações de arranque e o endereço do relay — configure o telemóvel conforme a secção «Configurar na app»
config.example.json (exemplo de configuração)
{
  "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"
      ]
    }
  ]
}
relay.mjs (programa completo, guarde como relay.mjs)
#!/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>';
}

Configurar na app CosmosBox

  1. 1Abra a app → Definições → Definições de IA
  2. 2«Os meus fornecedores» → Adicionar fornecedor
  3. 3Nome: relay (qualquer); BaseURL: http://<IP-do-dispositivo-relay>:<porta>/v1; Chave API: a sua chave de relay; Modelo: qualquer nome de modelo de config.json (ex.: deepseek-v4-flash)
  4. 4Volte ao chat IA / programação IA / geração de plugins IA e escolha o fornecedor que adicionou — as três ferramentas partilham a mesma configuração
⚠️
O telemóvel e o dispositivo relay têm de estar no mesmo Wi-Fi (mesma LAN). O IP do relay aparece no registo de arranque do relay ou nas definições de rede do dispositivo.

Usar fora de casa (acesso remoto)

Por predefinição, o relay só funciona no seu Wi-Fi doméstico. Para usá-lo na rua, precisa de um túnel encriptado que ligue o relay de casa à internet. Duas opções — nada está integrado na app; você escolhe:

Opção 1: frp + o seu próprio servidor (recomendada, estável na China)

Use o seu próprio servidor cloud (painel BT / Linux, 2 núcleos 2 GB chega) como túnel: o dispositivo relay liga-se em saída ao servidor e o telemóvel acede a https://o-seu-dominio/v1. Encriptado de ponta a ponta; o servidor não guarda chaves — mesmo invadido, os atacantes não obtêm nada.

  1. 1Arranje um servidor cloud (2 núcleos 2 GB), instale o painel BT e o Nginx (ignore se já instalado)
  2. 2Aponte um domínio (ex.: relay.example.com) para o IP do servidor
  3. 3Carregue server-frps-guide.sh para o servidor e execute como root: bash server-frps-guide.sh relay.example.com — o script faz automaticamente: baixar frp, gerar Token aleatório, autostart systemd, proxy inverso Nginx, certificado HTTPS, limite de velocidade
  4. 4Abra TCP 7000 no grupo de segurança da cloud (porta de ligação dos relays)
  5. 5Anote o Token impresso pelo script (necessário para configurar o dispositivo relay)
  6. 6💡 Pode dar esta página a qualquer IA e ela implementa tudo seguindo o script (cada linha tem comentário)
server-frps-guide.sh (implementação de servidor em um clique, pode ser entregue a qualquer IA)
#!/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 "========================================"
  1. 1No dispositivo relay (telemóvel antigo com Termux) execute: bash install-frpc.sh
  2. 2Siga o assistente: endereço do servidor, porta 7000, Token, domínio de acesso, porta local do relay (8000 por predefinição)
  3. 3Com o túnel ativo, ponha a BaseURL da app em https://o-seu-dominio/v1 — use na rua; em casa o endereço LAN também funciona, ambos coexistem
install-frpc.sh (configuração do túnel no dispositivo relay)
#!/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 "========================================"

Opção 2: Tailscale (zero configuração de servidor, alternativa)

  1. 1Instale Tailscale no dispositivo relay (app Android ou Termux) e no telemóvel, entre com a mesma conta
  2. 2Quando os dois extremos estiverem na mesma rede virtual, ponha a BaseURL da app em http://IP-Tailscale-do-relay:8000/v1
  3. 3Criptografia WireGuard completa e acessível de qualquer rede; desvantagem: o Tailscale é um serviço estrangeiro, por vezes instável nas redes chinesas

Política de segurança (porque não temer os atacantes)

  • 🔒frps é um reencaminhador puro sem estado: não guarda chaves API nem dados de utilizadores — mesmo um servidor invadido não entrega nada
  • 🔒frpc ↔ frps é encriptado TLS de ponta a ponta; os seus pedidos não podem ser espionados na internet
  • 🔒Autenticação por Token: dispositivos não autorizados não podem criar túneis
  • 🔒O painel de administração do frps só escuta em localhost, nunca exposto
  • 🔒A camada de proxy Nginx tem limite de velocidade (10 pedidos/s) e teto de tamanho de pedido contra abusos
  • 🔒As suas chaves API dos fornecedores estão sempre apenas no dispositivo relay (config.json); a app só guarda a chave do relay

Perguntas frequentes

Posso usar o relay fora de casa?

Sim. Use frp + o seu próprio servidor (recomendado, ver secção «Usar fora de casa») ou Tailscale. Com o túnel ativo, ponha a BaseURL da app no endereço público.

O servidor do relay pode ser atacado? As minhas chaves vazam?

frps é um reencaminhador puro que não guarda chaves; o túnel é totalmente encriptado TLS com autenticação por token; o painel nunca é exposto; a camada Nginx limita a velocidade. Mesmo invadido, os atacantes não obtêm nenhuma das suas chaves API.

O relay consome muitos recursos?

Não. Apenas reencaminha pedidos e nunca executa modelos — o uso de memória costuma ser de algumas dezenas de MB, nada para um telemóvel antigo; em repouso quase não gasta bateria.

O relay continua a funcionar após reiniciar o telemóvel?

O relay no Termux não arranca automaticamente ao ligar. Após um reinício, execute bash install-termux.sh novamente — o script deteta a configuração existente e arranca diretamente.

Posso usar fora do mesmo Wi-Fi?

Sim, mas não recomendamos expor a porta diretamente à Internet. Use o Tailscale para pôr o telemóvel e o dispositivo relay na mesma rede virtual — acessível a partir de qualquer rede e totalmente encriptado.

O relay é seguro? As minhas chaves podem vazar?

As chaves ficam guardadas apenas no seu dispositivo relay; o telemóvel guarda a chave do relay e não as chaves de cada fornecedor. Altere definitivamente relayKey em config.json para uma cadeia aleatória longa e desative o reencaminhamento de portas da porta do relay no router.

Que modelos e fornecedores são suportados?

Qualquer fornecedor compatível com OpenAI: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI, Gemini (endpoint compatível com OpenAI), Claude (via OpenRouter), etc. Adicione-os um a um na lista upstreams de config.json; o encaminhamento suporta correspondência exata e por prefixo.

Como migro ao mudar de dispositivo?

Copie config.json (contém as suas chaves de fornecedores) do diretório do relay para o novo dispositivo e execute relay.mjs ou o script de um clique. No telemóvel, mude apenas o IP na BaseURL para o IP do novo dispositivo.

MODO MATRIX: LIGADO — repita ou prima ESC para sair