chore(scripts): add development, DSH and sidecar launchers
This commit is contained in:
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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')
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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}`))
|
||||
@@ -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:-<cloudflared 未启动>}/admin"
|
||||
echo "=========================================="
|
||||
@@ -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
|
||||
@@ -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 </dev/null >/tmp/yawan-wechat-sidecar.log 2>&1 &)
|
||||
fi
|
||||
Reference in New Issue
Block a user