chore(scripts): add development, DSH and sidecar launchers

This commit is contained in:
yi
2026-09-16 19:06:21 +08:00
parent 85f6e3be86
commit 470f8f01ab
11 changed files with 502 additions and 0 deletions
+40
View File
@@ -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)"
+20
View File
@@ -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"
+34
View File
@@ -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')
+30
View File
@@ -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"
+7
View File
@@ -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
+29
View File
@@ -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"
+59
View File
@@ -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()
+184
View File
@@ -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}`))