From 470f8f01aba47a10160c7bb6921a850523f9175b Mon Sep 17 00:00:00 2001 From: yi <100551693+yi1108@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:06:21 +0800 Subject: [PATCH] chore(scripts): add development, DSH and sidecar launchers --- scripts/check_storage.sh | 40 +++++++ scripts/dev_start.sh | 20 ++++ scripts/pack.py | 34 ++++++ scripts/prod_start.sh | 30 +++++ scripts/start_dsh.bat | 7 ++ scripts/start_dsh.sh | 29 +++++ scripts/wechat_bot_adapter.mjs | 59 ++++++++++ scripts/wechat_ilink_sidecar.mjs | 184 +++++++++++++++++++++++++++++++ 启动公网隧道.sh | 43 ++++++++ 启动工作台.bat | 9 ++ 守护隧道.sh | 47 ++++++++ 11 files changed, 502 insertions(+) create mode 100644 scripts/check_storage.sh create mode 100644 scripts/dev_start.sh create mode 100644 scripts/pack.py create mode 100644 scripts/prod_start.sh create mode 100644 scripts/start_dsh.bat create mode 100644 scripts/start_dsh.sh create mode 100644 scripts/wechat_bot_adapter.mjs create mode 100644 scripts/wechat_ilink_sidecar.mjs create mode 100644 启动公网隧道.sh create mode 100644 启动工作台.bat create mode 100644 守护隧道.sh diff --git a/scripts/check_storage.sh b/scripts/check_storage.sh new file mode 100644 index 0000000..5841b58 --- /dev/null +++ b/scripts/check_storage.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# 生产数据盘健康检查。 +# 生产 server/data 当前是指向外接盘的符号链接;若磁盘未挂载, +# 启动服务会把数据写到错误位置或直接失败,因此在启动前明确阻止。 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DATA_LINK="$ROOT/server/data" +MIN_FREE_GIB="${YAWAN_MIN_FREE_GIB:-100}" + +if [ ! -L "$DATA_LINK" ]; then + echo "storage check failed: $DATA_LINK is not a symlink to the external data disk" >&2 + exit 2 +fi + +TARGET="$(readlink "$DATA_LINK")" +if [ "${TARGET#/}" = "$TARGET" ]; then + TARGET="$(cd "$(dirname "$DATA_LINK")" && cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" +fi +if [ ! -d "$TARGET" ]; then + echo "storage check failed: external data path is unavailable: $TARGET" >&2 + exit 2 +fi + +# df -Pk reports available KiB in column 4. Keep arithmetic in KiB to avoid +# locale-dependent human-readable output and require at least 100 GiB free. +AVAILABLE_KIB="$(df -Pk "$TARGET" | awk 'NR==2 {print $4}')" +case "$AVAILABLE_KIB" in + ''|*[!0-9]*) + echo "storage check failed: cannot read free space for $TARGET" >&2 + exit 2 + ;; +esac +MIN_FREE_KIB=$((MIN_FREE_GIB * 1024 * 1024)) +if [ "$AVAILABLE_KIB" -lt "$MIN_FREE_KIB" ]; then + echo "storage check failed: $TARGET has ${AVAILABLE_KIB} KiB free; require ${MIN_FREE_GIB} GiB" >&2 + exit 3 +fi + +echo "storage ok: $TARGET has ${AVAILABLE_KIB} KiB free (minimum ${MIN_FREE_GIB} GiB)" diff --git a/scripts/dev_start.sh b/scripts/dev_start.sh new file mode 100644 index 0000000..9038f0a --- /dev/null +++ b/scripts/dev_start.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# 开发环境启动脚本(8011,独立数据 data-dev/,可随便折腾不影响生产) +# 用法: bash scripts/dev_start.sh [restart] +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PORT=8011 +export YAWAN_ENV=dev + +# dev 单实例收敛:先杀旧 dev +pkill -f "uvicorn.*8011" 2>/dev/null || true +sleep 1 + +cd "$ROOT/server" +if [ -f .venv/bin/activate ]; then PY=.venv/bin/python; else PY=python3; fi + +# 首次启动 dev 库自动初始化(bootstrap admin 走页面上注册流程即可) +nohup env YAWAN_ENV=dev "$PY" -m uvicorn app.main:app --host 127.0.0.1 --port $PORT >> "$ROOT/server/data-dev/dev.log" 2>&1 & +echo "dev 已启动: http://127.0.0.1:$PORT/admin (日志 $ROOT/server/data-dev/dev.log)" +sleep 3 +curl -s -m 5 --noproxy '*' -o /dev/null -w "dev health: %{http_code}\n" "http://127.0.0.1:$PORT/api/health" diff --git a/scripts/pack.py b/scripts/pack.py new file mode 100644 index 0000000..4e82605 --- /dev/null +++ b/scripts/pack.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""源码快照打包(原 /tmp/pack.py 丢失,重写)。""" +import hashlib +import os +import zipfile +from pathlib import Path +from datetime import datetime + +ROOT = Path('/Users/macclaw/Desktop/yawan-workbench') +OUT = Path('/Users/macclaw/Desktop/私域Agent-重构版-2026-08-29.zip') + +INCLUDE_DIRS = ['server/app', 'server/tests', 'web/src', 'web/dist', 'scripts', 'docs'] +INCLUDE_FILES = ['server/requirements.txt', 'web/package.json', 'web/vite.config.ts', + 'web/tsconfig.json', 'web/index.html', 'MIGRATION.md', + '系统功能逻辑文档.html', '用户使用手册.html'] + +count = 0 +with zipfile.ZipFile(OUT, 'w', zipfile.ZIP_DEFLATED) as z: + for d in INCLUDE_DIRS: + base = ROOT / d + if not base.exists(): + continue + for f in base.rglob('*'): + if f.is_file() and '__pycache__' not in str(f) and 'node_modules' not in str(f): + z.write(f, f'私域Agent/{d}/{f.relative_to(base)}') + count += 1 + for fp in INCLUDE_FILES: + f = ROOT / fp + if f.exists() and f.is_file(): + z.write(f, f'私域Agent/{fp}') + count += 1 + +size = OUT.stat().st_size +print(f'源码包: {count} 文件 | {size/1024:.0f} KB') diff --git a/scripts/prod_start.sh b/scripts/prod_start.sh new file mode 100644 index 0000000..57275d9 --- /dev/null +++ b/scripts/prod_start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# 生产环境启动(8010,生产数据 data/)——dev_start 的对照脚本,生产平时由 launchd/guard 守护,此脚本用于手动收敛 +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export YAWAN_ENV=prod +# Fail fast when the external data disk is disconnected or nearly full. This +# prevents accidentally starting against an empty local ``server/data`` path. +bash "$ROOT/scripts/check_storage.sh" +# 可选生产配置(不提交密钥):复制 .env.production.example 为 .env.production 后填写。 +if [ -f "$ROOT/.env.production" ]; then + set -a + . "$ROOT/.env.production" + set +a +fi +bash /tmp/restart_one.sh 2>/dev/null || { + # Older detached launches used ``python -c ... uvicorn.run(...port=8010)`` + # and therefore escaped the old uvicorn-only pattern. Stop every process + # serving this port, then reap matching detached command lines as well. + pids="$(lsof -tiTCP:8010 -sTCP:LISTEN 2>/dev/null || true)" + if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; fi + pkill -f "(uvicorn.*8010|port=8010).*app\.main" 2>/dev/null || true + sleep 1 + pids="$(lsof -tiTCP:8010 -sTCP:LISTEN 2>/dev/null || true)" + if [ -n "$pids" ]; then kill -9 $pids 2>/dev/null || true; fi + cd "$ROOT/server" + PY=.venv/bin/python + nohup env YAWAN_ENV=prod "$PY" -m uvicorn app.main:app --host 127.0.0.1 --port 8010 >> /tmp/yawan-uvicorn.log 2>&1 & + sleep 3 +} +curl -s -m 5 --noproxy '*' -o /dev/null -w "prod health: %{http_code}\n" "http://127.0.0.1:8010/api/health" diff --git a/scripts/start_dsh.bat b/scripts/start_dsh.bat new file mode 100644 index 0000000..ad4502e --- /dev/null +++ b/scripts/start_dsh.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d %~dp0..\server +if not defined YAWAN_ENV set YAWAN_ENV=prod +if not defined YAWAN_EXECUTION_BACKEND set YAWAN_EXECUTION_BACKEND=dsh +if not defined YAWAN_DSH_LOG_LEVEL set YAWAN_DSH_LOG_LEVEL=INFO +python -m app.dsh_worker diff --git a/scripts/start_dsh.sh b/scripts/start_dsh.sh new file mode 100644 index 0000000..d155514 --- /dev/null +++ b/scripts/start_dsh.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# 启动独立 DSH 内容生产 Worker。API 与 DSH 必须使用同一个 YAWAN_DATA_DIR, +# 以共享 SQLite 队列、知识库、向量索引和 artifacts 产物目录。 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/server" + +if [ -f "$ROOT/.env.production" ]; then + set -a + . "$ROOT/.env.production" + set +a +fi + +export YAWAN_ENV="${YAWAN_ENV:-prod}" +export YAWAN_EXECUTION_BACKEND="${YAWAN_EXECUTION_BACKEND:-dsh}" +export YAWAN_DSH_LOG_LEVEL="${YAWAN_DSH_LOG_LEVEL:-INFO}" + +if [ -x "$ROOT/server/.venv/bin/python" ]; then + PY="$ROOT/server/.venv/bin/python" +elif [ -x "$ROOT/.venv/bin/python" ]; then + PY="$ROOT/.venv/bin/python" +else + PY="${PYTHON:-python3}" +fi + +LOG_FILE="${YAWAN_DSH_LOG_FILE:-/tmp/yawan-dsh.log}" +nohup "$PY" -m app.dsh_worker >> "$LOG_FILE" 2>&1 & +pid=$! +echo "DSH worker started: pid=$pid log=$LOG_FILE backend=$YAWAN_EXECUTION_BACKEND env=$YAWAN_ENV" diff --git a/scripts/wechat_bot_adapter.mjs b/scripts/wechat_bot_adapter.mjs new file mode 100644 index 0000000..2a3b874 --- /dev/null +++ b/scripts/wechat_bot_adapter.mjs @@ -0,0 +1,59 @@ +/** + * Wechaty -> 私域 Agent API 适配器(示例) + * + * 安装:npm i wechaty wechaty-puppet-wechat4u + * 启动:YAWAN_API_URL=https://agent.example.com \ + * YAWAN_WECHAT_BOT_SECRET=... YAWAN_WECHAT_AGENT_ID=3 node scripts/wechat_bot_adapter.mjs + * + * 个人微信协议有封号风险。生产环境建议把本适配层替换为腾讯官方 + * openclaw-weixin/iLink Bot,只保留下面 callAgent() 的 HTTP 调用。 + */ +import crypto from 'node:crypto' +import { WechatyBuilder, Message } from 'wechaty' + +const apiUrl = (process.env.YAWAN_API_URL || 'http://127.0.0.1:8010').replace(/\/$/, '') +const secret = process.env.YAWAN_WECHAT_BOT_SECRET || '' +const agentId = Number(process.env.YAWAN_WECHAT_AGENT_ID || 0) +if (!secret || !agentId) throw new Error('请设置 YAWAN_WECHAT_BOT_SECRET 和 YAWAN_WECHAT_AGENT_ID') + +async function callAgent (conversationId, messageId, text) { + const timestamp = String(Math.floor(Date.now() / 1000)) + const payload = `${timestamp}.${conversationId}.${messageId}.${text}` + const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex') + const response = await fetch(`${apiUrl}/api/wechat/bot/reply`, { + method: 'POST', + headers: { 'content-type': 'application/json', + 'x-wechat-bot-timestamp': timestamp, 'x-wechat-bot-signature': signature }, + body: JSON.stringify({ agent_id: agentId, conversation_id: conversationId, + message_id: messageId, text }) + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.detail || `API ${response.status}`) + return data.reply || '' +} + +const bot = WechatyBuilder.build({ + name: process.env.YAWAN_WECHAT_SESSION || 'yawan-agent', + puppet: 'wechaty-puppet-wechat4u', + puppetOptions: { uos: true } +}) +bot.on('scan', (qrcode, status) => console.log('请扫码登录微信:', status, qrcode)) +bot.on('login', user => console.log('微信 Bot 已登录:', user.name())) +bot.on('logout', user => console.log('微信 Bot 已退出:', user.name())) +bot.on('message', async message => { + if (message.self() || message.type() !== Message.Type.Text) return + const text = message.text().trim() + if (!text) return + const room = await Promise.resolve(message.room()) + const from = await Promise.resolve(message.from()) + const conversationId = room?.id || from?.id || 'unknown' + const messageId = typeof message.id === 'function' ? message.id() : (message.id || crypto.randomUUID()) + try { + const reply = await callAgent(conversationId, messageId, text) + if (reply) await message.say(reply) + } catch (error) { + console.error('Agent 对话失败:', error.message) + await message.say('系统暂时繁忙,请稍后再试。') + } +}) +await bot.start() diff --git a/scripts/wechat_ilink_sidecar.mjs b/scripts/wechat_ilink_sidecar.mjs new file mode 100644 index 0000000..667e920 --- /dev/null +++ b/scripts/wechat_ilink_sidecar.mjs @@ -0,0 +1,184 @@ +/** 腾讯/ CoreSpeed iLink 多账号 sidecar。 + * npm i @wechatbot/wechatbot + * Node.js >= 22 required by @wechatbot/wechatbot. + * YAWAN_API_URL=https://agent.pducv.cn /opt/homebrew/opt/node@22/bin/node scripts/wechat_ilink_sidecar.mjs + */ +import http from 'node:http' +import crypto from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { WeChatBot } from '@wechatbot/wechatbot' + +const apiUrl = (process.env.YAWAN_API_URL || 'http://127.0.0.1:8010').replace(/\/$/, '') +const port = Number(process.env.YAWAN_WECHAT_SIDECAR_PORT || 8765) +const accounts = new Map() + +/** 将 iLink 登录状态同步回主系统。密钥只通过本机 sidecar 到后端传输, + * 主系统不会把 bot secret 返回给前端或写入日志。 */ +async function notifySystem (account, status, extra = {}) { + if (!account?.botSecret || !account?.id) return + try { + const response = await fetch(`${apiUrl}/api/wechat/bot/accounts/${account.id}/callback`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-wechat-bot-secret': account.botSecret, + }, + body: JSON.stringify({ status, ...extra }), + }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + console.error(`微信状态同步失败 ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`) + } + } catch (error) { + console.error('微信状态同步失败', error.message) + } +} + +function resolveStorageDir (value, accountId) { + const raw = String(value || path.join(os.homedir(), '.wechatbot')) + const expanded = raw === '~' ? os.homedir() : raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(2)) : raw + return path.join(path.resolve(expanded), String(accountId)) +} + +async function callSystem (account, text, messageId, conversationId) { + const timestamp = String(Math.floor(Date.now() / 1000)) + const payload = `${timestamp}.${conversationId}.${messageId}.${text}` + const signature = crypto.createHmac('sha256', account.botSecret).update(payload).digest('hex') + const response = await fetch(`${apiUrl}/api/wechat/bot/reply`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-wechat-bot-secret': account.botSecret, + 'x-wechat-bot-timestamp': timestamp, 'x-wechat-bot-signature': signature }, + body: JSON.stringify({ account_id: account.id, agent_id: account.agentId, + conversation_id: conversationId, message_id: messageId, text }) + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.detail || `系统 API ${response.status}`) + return data.reply || '' +} + +function json (res, status, body) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(body)) +} + +async function readBody (req) { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') +} + +const server = http.createServer(async (req, res) => { + try { + if (req.method === 'POST' && req.url === '/accounts/connect') { + const body = await readBody(req) + if (!body.account_id || !body.bot_secret || !body.agent_id) return json(res, 422, { detail: '缺少账号参数' }) + let account = accounts.get(Number(body.account_id)) + // 删除或失败后的账号允许重新扫码;清理旧实例,避免复用过期凭据。 + if (account && ['disconnected', 'error'].includes(account.status)) { + account.stopping = true + account.bot?.stop() + accounts.delete(account.id) + account = undefined + } + if (!account) { + account = { id: Number(body.account_id), agentId: Number(body.agent_id), botSecret: String(body.bot_secret), status: 'pending_qr', qrCode: '', stopping: false } + // loginCallbacks 不是 WeChatBot 构造参数的运行时配置;登录时必须传入 + // callbacks。storage 按账号隔离,避免多账号复用同一份凭据。 + const bot = new WeChatBot({ storage: 'file', storageDir: resolveStorageDir(process.env.YAWAN_WECHAT_STORAGE, account.id), + botAgent: 'YawanWorkbench/1.0 (iLink)' }) + const loginCallbacks = { + onQrUrl: url => { + account.qrCode = String(url || '') + account.status = 'pending_qr' + void notifySystem(account, 'pending_qr', { qr_code: account.qrCode, last_error: '' }) + }, + onScanned: () => { void notifySystem(account, 'pending_qr', { qr_code: account.qrCode, last_error: '' }) }, + onExpired: () => { + account.qrCode = '' + void notifySystem(account, 'pending_qr', { qr_code: '', last_error: '二维码已过期,正在刷新' }) + }, + } + account.bot = bot; accounts.set(account.id, account) + // SDK 处理 session:expired 时会通过 this.login({ force: true }) 自动重登, + // 包装 login 让自动重登同样回传二维码。 + const originalLogin = bot.login.bind(bot) + bot.login = (options = {}) => originalLogin({ + ...options, + callbacks: { ...(options.callbacks || {}), ...loginCallbacks }, + }) + bot.on('login', creds => { + account.status = 'connected'; account.qrCode = '' + const externalAccountId = creds?.accountId + account.externalAccountId = externalAccountId ? String(externalAccountId) : '' + void notifySystem(account, 'connected', externalAccountId ? { external_account_id: String(externalAccountId) } : {}) + }) + bot.on('session:restored', creds => { + account.status = 'connected'; account.qrCode = '' + const externalAccountId = creds?.accountId || account.externalAccountId + void notifySystem(account, 'connected', externalAccountId ? { external_account_id: String(externalAccountId) } : {}) + }) + bot.on('session:expired', () => { + account.status = 'pending_qr' + account.qrCode = '' + void notifySystem(account, 'pending_qr', { qr_code: '', last_error: '微信登录已过期,请重新扫码' }) + }) + bot.on('error', error => { + account.status = 'error'; account.lastError = error?.message || String(error) + void notifySystem(account, 'error', { last_error: account.lastError }) + }) + bot.on('poll:stop', () => { + if (account.stopping) void notifySystem(account, 'disconnected', { qr_code: '', last_error: '' }) + }) + bot.on('close', () => { + if (account.stopping) void notifySystem(account, 'disconnected', { qr_code: '', last_error: '' }) + }) + bot.onMessage(async msg => { + if (!msg?.text) return + const conversationId = String(msg.userId || 'unknown') + const messageId = String(msg.messageId || msg.raw?.msg_id || `${conversationId}:${Date.now()}`) + try { await bot.sendTyping(conversationId); const reply = await callSystem(account, msg.text, messageId, conversationId); if (reply) await bot.reply(msg, reply) } catch (error) { console.error('微信消息转发失败', error.message) } + }) + void bot.login().then(() => bot.start()).catch(error => { + account.status = 'error'; account.lastError = error instanceof Error ? error.message : String(error) + void notifySystem(account, 'error', { last_error: account.lastError }) + }) + } + return json(res, 200, { status: account.status, qr_code: account.qrCode, external_account_id: account.externalAccountId || '' }) + } + const match = req.url?.match(/^\/accounts\/(\d+)$/) + // 后端服务与 sidecar 通过本机回环通信;绑定 Agent 变更时同步内存配置。 + // sidecar 仅监听 127.0.0.1,公网请求无法直接访问此路由。 + if (req.method === 'PATCH' && match) { + const account = accounts.get(Number(match[1])) + if (!account) return json(res, 404, { detail: 'not found' }) + const body = await readBody(req) + if (body.agent_id !== undefined) { + const agentId = Number(body.agent_id) + if (!Number.isInteger(agentId) || agentId <= 0) return json(res, 422, { detail: '无效的 Agent' }) + account.agentId = agentId + } + return json(res, 200, { ok: true, account_id: account.id, agent_id: account.agentId }) + } + if (req.method === 'GET' && match) { + const account = accounts.get(Number(match[1])); return json(res, account ? 200 : 404, account ? { + status: account.status, + qr_code: account.qrCode, + last_error: account.lastError || '', + external_account_id: account.externalAccountId || '', + } : { detail: 'not found' }) + } + if (req.method === 'DELETE' && match) { + const account = accounts.get(Number(match[1])) + if (account) { + account.stopping = true + account.bot?.stop() + await notifySystem(account, 'disconnected', { qr_code: '', last_error: '' }) + accounts.delete(account.id) + } + return json(res, 200, { ok: true }) + } + json(res, 404, { detail: 'not found' }) + } catch (error) { json(res, 500, { detail: error.message }) } +}) +server.listen(port, '127.0.0.1', () => console.log(`微信 iLink sidecar listening on ${port}`)) diff --git a/启动公网隧道.sh b/启动公网隧道.sh new file mode 100644 index 0000000..2d2637b --- /dev/null +++ b/启动公网隧道.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# 私域Agent 公网隧道启动脚本(主推 cloudflared 无拦截页 + ngrok 应急) +# 用法: bash ~/Desktop/yawan-workbench/启动公网隧道.sh + +set -u + +# 0. 后端 +if lsof -ti :8010 >/dev/null 2>&1; then + echo "✓ 后端已在运行 (port 8010)" +else + echo "▶ 启动后端…" + (cd "$(dirname "$0")/server" && nohup .venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8010 >/tmp/yawan-uvicorn.log 2>&1 &) + sleep 4 + curl -s -m 5 --noproxy '*' http://127.0.0.1:8010/api/health >/dev/null \ + && echo "✓ 后端就绪" || { echo "✗ 后端启动失败,看 /tmp/yawan-uvicorn.log"; exit 1; } +fi + +# 1. cloudflared 主隧道(无 ngrok 拦截页) +CF_METRICS=20241 +if lsof -ti :$CF_METRICS >/dev/null 2>&1; then + CF_URL=$(curl -s -m 5 --noproxy '*' "http://127.0.0.1:$CF_METRICS/metrics" | grep -o 'https://[a-z-]*\.trycloudflare\.com' | head -1) + echo "✓ cloudflared 已在运行: $CF_URL" +else + echo "▶ 启动 cloudflared 主隧道…" + nohup /opt/homebrew/bin/cloudflared tunnel --url http://127.0.0.1:8010 --protocol http2 --metrics "127.0.0.1:$CF_METRICS" >/tmp/yawan-cloudflared.log 2>&1 & + sleep 8 + CF_URL=$(curl -s -m 5 --noproxy '*' "http://127.0.0.1:$CF_METRICS/metrics" | grep -o 'https://[a-z-]*\.trycloudflare\.com' | head -1) + [ -n "$CF_URL" ] && echo "✓ 主入口(cloudflared·无拦截页): $CF_URL/admin" || echo "✗ cloudflared 启动失败,看 /tmp/yawan-cloudflared.log" +fi + +# 2. ngrok 应急隧道(地址固定,但有拦截确认页) +if ! pgrep -f 'ngrok http 8010' >/dev/null 2>&1; then + echo "▶ 启动 ngrok 应急隧道…" + nohup /opt/homebrew/bin/ngrok http 8010 --host-header=localhost:8010 --log stdout >/tmp/yawan-ngrok.log 2>&1 & + sleep 4 +fi +NGROK_URL=$(curl -s -m 5 --noproxy '*' http://127.0.0.1:4040/api/tunnels 2>/dev/null | grep -o 'https://[a-z0-9-]*\.ngrok-free\.dev' | head -1) +[ -n "$NGROK_URL" ] && echo "✓ 应急入口(ngrok·有拦截页): $NGROK_URL/admin" + +echo "" +echo "========== 把主入口发给测试用户 ==========" +echo " ${CF_URL:-}/admin" +echo "==========================================" diff --git a/启动工作台.bat b/启动工作台.bat new file mode 100644 index 0000000..ed95950 --- /dev/null +++ b/启动工作台.bat @@ -0,0 +1,9 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0server" +echo [1/2] 启动后端 http://127.0.0.1:8010 ... +start "雅文工作台-后端" cmd /k ".venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8010" +echo [2/2] 启动前端 http://127.0.0.1:5173 ... +cd /d "%~dp0web" +start "雅文工作台-前端" cmd /k "pnpm dev" +echo 浏览器打开 http://127.0.0.1:5173 diff --git a/守护隧道.sh b/守护隧道.sh new file mode 100644 index 0000000..d18140b --- /dev/null +++ b/守护隧道.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# 私域Agent 隧道守护:每 60 秒巡检一轮,谁挂拉谁。由 launchd 常驻调用(KeepAlive)。 +# 日志: /tmp/yawan-guard.log + +ROOT="/Users/macclaw/Desktop/yawan-workbench" +# launchd 守护进程没有交互式 shell 环境,主动读取生产配置。 +if [ -f "$ROOT/.env.production" ]; then + set -a + . "$ROOT/.env.production" + set +a +fi + +BACKEND_OK() { curl -s -m 4 --noproxy '*' http://127.0.0.1:8010/api/health >/dev/null 2>&1; } +CF_ALIVE() { pgrep -f 'cloudflared tunnel --url http://127.0.0.1:8010' >/dev/null 2>&1; } +NGROK_ALIVE(){ pgrep -f 'ngrok http 8010' >/dev/null 2>&1; } +WECHAT_ALIVE(){ lsof -tiTCP:8765 -sTCP:LISTEN >/dev/null 2>&1; } + +# ---- 后端 ---- +if ! BACKEND_OK; then + echo "[$(date '+%m-%d %H:%M:%S')] 后端挂了 → 拉起" + pkill -9 -f 'uvicorn app.main:app' 2>/dev/null + (cd "$ROOT/server" && nohup env YAWAN_ENV=prod .venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8010 >/tmp/yawan-uvicorn.log 2>&1 &) + sleep 5 +fi + +# ---- cloudflared 主入口 ---- +if ! CF_ALIVE; then + echo "[$(date '+%m-%d %H:%M:%S')] cloudflared 挂了 → 拉起" + pkill -9 -f 'cloudflared tunnel' 2>/dev/null + nohup /opt/homebrew/bin/cloudflared tunnel --url http://127.0.0.1:8010 --protocol http2 --metrics 127.0.0.1:20241 >/tmp/yawan-cloudflared.log 2>&1 & + sleep 6 + CF=$(curl -s -m 5 --noproxy '*' http://127.0.0.1:20241/metrics 2>/dev/null | grep -o 'https://[a-z-]*\.trycloudflare\.com' | head -1) + [ -n "$CF" ] && echo "[$(date '+%m-%d %H:%M:%S')] 新备用地址: $CF/admin" +fi + +# ---- ngrok 应急(地址固定)---- +if ! NGROK_ALIVE; then + echo "[$(date '+%m-%d %H:%M:%S')] ngrok 挂了 → 拉起" + pkill -9 -f 'ngrok http 8010' 2>/dev/null + nohup /opt/homebrew/bin/ngrok http 8010 --host-header=localhost:8010 --log stdout >/tmp/yawan-ngrok.log 2>&1 & +fi + +# ---- 微信 iLink sidecar ---- +if ! WECHAT_ALIVE && [ -f "$ROOT/scripts/wechat_ilink_sidecar.mjs" ]; then + echo "[$(date '+%m-%d %H:%M:%S')] 微信 iLink sidecar 挂了 → 拉起" + (cd "$ROOT" && nohup /opt/homebrew/opt/node@22/bin/node scripts/wechat_ilink_sidecar.mjs /tmp/yawan-wechat-sidecar.log 2>&1 &) +fi