feat: AI助手支持 Anthropic/Gemini 原生API + 修复Windows终端闪烁

- AI助手新增 API 类型选择器(OpenAI兼容 / Anthropic原生 / Google Gemini)
- 实现 Anthropic Messages API 流式调用 + 工具调用(tool_use/tool_result)
- 实现 Google Gemini streamGenerateContent + 工具调用(functionCall)
- 设置弹窗动态切换 placeholder 和提示文本
- 测试按钮和模型拉取适配三种 API 类型
- 修复 Windows 上 Gateway 状态轮询导致终端反复闪烁(execSync/spawn 加 windowsHide)
- 默认密码统一为 123456 + 改密码后自动移除顶部横幅
- 后端 API 增加暴力破解保护、配置缓存、请求体大小限制
This commit is contained in:
晴天
2026-03-06 22:46:40 +08:00
parent 80197bdc60
commit 921c371934
23 changed files with 2017 additions and 238 deletions

View File

@@ -21,15 +21,123 @@ const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const SCOPES = ['operator.admin', 'operator.approvals', 'operator.pairing', 'operator.read', 'operator.write']
const PANEL_CONFIG_PATH = path.join(OPENCLAW_DIR, 'clawpanel.json')
// === 访问密码 & Session 管理 ===
const _sessions = new Map() // token → { expires }
const SESSION_TTL = 24 * 60 * 60 * 1000 // 24h
const AUTH_EXEMPT = new Set(['auth_check', 'auth_login', 'auth_logout'])
// 登录限速防暴力破解IP 级别5次失败后锁定60秒
const _loginAttempts = new Map() // ip → { count, lockedUntil }
const MAX_LOGIN_ATTEMPTS = 5
const LOCKOUT_DURATION = 60 * 1000 // 60s
function checkLoginRateLimit(ip) {
const now = Date.now()
const record = _loginAttempts.get(ip)
if (!record) return null
if (record.lockedUntil && now < record.lockedUntil) {
const remaining = Math.ceil((record.lockedUntil - now) / 1000)
return `登录失败次数过多,请 ${remaining} 秒后再试`
}
if (record.lockedUntil && now >= record.lockedUntil) {
_loginAttempts.delete(ip)
}
return null
}
function recordLoginFailure(ip) {
const record = _loginAttempts.get(ip) || { count: 0, lockedUntil: null }
record.count++
if (record.count >= MAX_LOGIN_ATTEMPTS) {
record.lockedUntil = Date.now() + LOCKOUT_DURATION
record.count = 0
}
_loginAttempts.set(ip, record)
}
function clearLoginAttempts(ip) {
_loginAttempts.delete(ip)
}
// 配置缓存避免每次请求同步读磁盘TTL 2秒写入时立即失效
let _panelConfigCache = null
let _panelConfigCacheTime = 0
const CONFIG_CACHE_TTL = 2000 // 2s
function readPanelConfig() {
const now = Date.now()
if (_panelConfigCache && (now - _panelConfigCacheTime) < CONFIG_CACHE_TTL) {
return JSON.parse(JSON.stringify(_panelConfigCache))
}
try {
if (fs.existsSync(PANEL_CONFIG_PATH)) {
_panelConfigCache = JSON.parse(fs.readFileSync(PANEL_CONFIG_PATH, 'utf8'))
_panelConfigCacheTime = now
return JSON.parse(JSON.stringify(_panelConfigCache))
}
} catch {}
return {}
}
function invalidateConfigCache() {
_panelConfigCache = null
_panelConfigCacheTime = 0
}
function getAccessPassword() {
return readPanelConfig().accessPassword || ''
}
function parseCookies(req) {
const obj = {}
;(req.headers.cookie || '').split(';').forEach(pair => {
const [k, ...v] = pair.trim().split('=')
if (k) obj[k] = decodeURIComponent(v.join('='))
})
return obj
}
function isAuthenticated(req) {
const pw = getAccessPassword()
if (!pw) return true // 未设密码,放行
const cookies = parseCookies(req)
const token = cookies.clawpanel_session
if (!token) return false
const session = _sessions.get(token)
if (!session || Date.now() > session.expires) {
_sessions.delete(token)
return false
}
return true
}
function checkPasswordStrength(pw) {
if (!pw || pw.length < 6) return '密码至少 6 位'
if (pw.length > 64) return '密码不能超过 64 位'
if (/^\d+$/.test(pw)) return '密码不能是纯数字'
const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw']
if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码'
return null // 通过
}
function isUnsafePath(p) {
return !p || p.includes('..') || p.includes('\0') || path.isAbsolute(p)
}
const MAX_BODY_SIZE = 1024 * 1024 // 1MB
function readBody(req) {
return new Promise((resolve) => {
let body = ''
req.on('data', chunk => body += chunk)
let size = 0
req.on('data', chunk => {
size += chunk.length
if (size > MAX_BODY_SIZE) { req.destroy(); resolve({}); return }
body += chunk
})
req.on('end', () => {
try { resolve(JSON.parse(body || '{}')) }
catch { resolve({}) }
@@ -201,6 +309,7 @@ function winStartGateway() {
detached: true,
stdio: ['ignore', out, err],
shell: true,
windowsHide: true,
cwd: homedir(),
})
child.unref()
@@ -210,7 +319,7 @@ function winStopGateway() {
const { running, pid } = winCheckGateway()
if (!running || !pid) throw new Error('Gateway 未运行')
try {
execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000 })
execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true })
} catch (e) {
throw new Error('停止失败: ' + (e.message || e))
}
@@ -220,7 +329,7 @@ function winCheckGateway() {
const port = readGatewayPort()
try {
// 用 netstat 精确查找监听指定端口的进程 PID
const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000 }).toString().trim()
const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000, windowsHide: true }).toString().trim()
if (!out) return { running: false, pid: null }
// 提取 PID最后一列
const parts = out.split('\n')[0].trim().split(/\s+/)
@@ -228,7 +337,7 @@ function winCheckGateway() {
if (!pid) return { running: false, pid: null }
// 验证进程是否为 node/openclaw排除其他程序碰巧占用同端口
try {
const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000 }).toString().trim()
const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000, windowsHide: true }).toString().trim()
const isGateway = /node|openclaw/i.test(taskOut)
return { running: isGateway, pid: isGateway ? pid : null }
} catch {
@@ -483,7 +592,7 @@ const handlers = {
check_node() {
try {
const ver = execSync('node --version 2>&1').toString().trim()
const ver = execSync('node --version 2>&1', { windowsHide: true }).toString().trim()
return { installed: true, version: ver }
} catch {
return { installed: false, version: null }
@@ -501,7 +610,7 @@ const handlers = {
} catch {}
}
if (!current) {
try { current = execSync('openclaw --version 2>&1').toString().trim().split(/\s+/).pop() } catch {}
try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {}
}
return { current, latest: null, update_available: false, source: 'chinese' }
},
@@ -573,7 +682,7 @@ const handlers = {
const logPath = path.join(LOGS_DIR, file)
if (!fs.existsSync(logPath)) return ''
try {
return execSync(`tail -${lines} "${logPath}" 2>&1`).toString()
return execSync(`tail -${lines} "${logPath}" 2>&1`, { windowsHide: true }).toString()
} catch {
const content = fs.readFileSync(logPath, 'utf8')
return content.split('\n').slice(-lines).join('\n')
@@ -714,8 +823,8 @@ const handlers = {
// Gateway 安装/卸载
install_gateway() {
try { execSync('openclaw --version 2>&1') } catch { throw new Error('openclaw CLI 未安装') }
return execSync('openclaw gateway install 2>&1').toString() || 'Gateway 服务已安装'
try { execSync('openclaw --version 2>&1', { windowsHide: true }) } catch { throw new Error('openclaw CLI 未安装') }
return execSync('openclaw gateway install 2>&1', { windowsHide: true }).toString() || 'Gateway 服务已安装'
},
upgrade_openclaw({ source = 'chinese' } = {}) {
@@ -723,7 +832,7 @@ const handlers = {
const pkg = source === 'official' ? '@anthropic-ai/claw' : '@qingchencloud/openclaw-zh'
const npmBin = isWindows ? 'npm.cmd' : 'npm'
try {
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000 }).toString()
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString()
return `升级完成 (${source})\n${out.slice(-200)}`
} catch (e) {
throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300))
@@ -931,6 +1040,22 @@ const handlers = {
return null
},
// === 访问密码认证 ===
auth_check() {
const pw = getAccessPassword()
return { required: !!pw, authenticated: false /* 由中间件覆写 */ }
},
auth_login() { throw new Error('由中间件处理') },
auth_logout() { throw new Error('由中间件处理') },
auth_set_password({ password }) {
const cfg = readPanelConfig()
cfg.accessPassword = password || ''
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
// 清除所有 session密码变更后强制重新登录
_sessions.clear()
return true
},
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
write_env_file({ path: p, config }) {
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
@@ -944,37 +1069,233 @@ const handlers = {
// === Vite 插件 ===
// 初始化:密码检测 + 启动日志 + 定时清理
function _initApi() {
const cfg = readPanelConfig()
if (!cfg.accessPassword && !cfg.ignoreRisk) {
cfg.accessPassword = '123456'
cfg.mustChangePassword = true
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
invalidateConfigCache()
console.log('[api] ⚠️ 首次启动,默认访问密码: 123456')
console.log('[api] ⚠️ 首次登录后将强制要求修改密码')
}
const pw = getAccessPassword()
console.log('[api] API 已启动,配置目录:', OPENCLAW_DIR)
console.log('[api] 平台:', isMac ? 'macOS' : process.platform)
console.log('[api] 访问密码:', pw ? '已设置' : (cfg.ignoreRisk ? '无视风险模式(无密码)' : '未设置'))
// 定时清理过期 session 和登录限速记录(每 10 分钟)
setInterval(() => {
const now = Date.now()
for (const [token, session] of _sessions) {
if (now > session.expires) _sessions.delete(token)
}
for (const [ip, record] of _loginAttempts) {
if (record.lockedUntil && now >= record.lockedUntil) _loginAttempts.delete(ip)
}
}, 10 * 60 * 1000)
}
// API 中间件dev server 和 preview server 共用)
async function _apiMiddleware(req, res, next) {
if (!req.url?.startsWith('/__api/')) return next()
const cmd = req.url.slice(7).split('?')[0]
// --- 认证特殊处理 ---
if (cmd === 'auth_check') {
const cfg = readPanelConfig()
const pw = cfg.accessPassword || ''
const isDefault = pw === '123456'
const resp = {
required: !!pw,
authenticated: !pw || isAuthenticated(req),
mustChangePassword: isDefault,
}
if (isDefault) resp.defaultPassword = '123456'
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(resp))
return
}
if (cmd === 'auth_login') {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || ''
const rateLimitErr = checkLoginRateLimit(clientIp)
if (rateLimitErr) {
res.statusCode = 429
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: rateLimitErr }))
return
}
const args = await readBody(req)
const cfg = readPanelConfig()
const pw = cfg.accessPassword || ''
if (!pw) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
if (args.password !== pw) {
recordLoginFailure(clientIp)
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '密码错误' }))
return
}
clearLoginAttempts(clientIp)
const token = crypto.randomUUID()
_sessions.set(token, { expires: Date.now() + SESSION_TTL })
res.setHeader('Set-Cookie', `clawpanel_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}`)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true, mustChangePassword: !!cfg.mustChangePassword }))
return
}
if (cmd === 'auth_change_password') {
const args = await readBody(req)
const cfg = readPanelConfig()
const pw = cfg.accessPassword || ''
if (pw && !isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录' }))
return
}
if (pw && args.oldPassword !== pw) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '当前密码错误' }))
return
}
const weakErr = checkPasswordStrength(args.newPassword)
if (weakErr) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: weakErr }))
return
}
if (args.newPassword === pw) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '新密码不能与旧密码相同' }))
return
}
cfg.accessPassword = args.newPassword
delete cfg.mustChangePassword
delete cfg.ignoreRisk
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
invalidateConfigCache()
_sessions.clear()
const token = crypto.randomUUID()
_sessions.set(token, { expires: Date.now() + SESSION_TTL })
res.setHeader('Set-Cookie', `clawpanel_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}`)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
if (cmd === 'auth_status') {
const cfg = readPanelConfig()
if (cfg.accessPassword && !isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录' }))
return
}
const isDefault = cfg.accessPassword === '123456'
const result = {
hasPassword: !!cfg.accessPassword,
mustChangePassword: isDefault,
ignoreRisk: !!cfg.ignoreRisk,
}
if (isDefault) {
result.defaultPassword = '123456'
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
return
}
if (cmd === 'auth_ignore_risk') {
if (!isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录' }))
return
}
const args = await readBody(req)
const cfg = readPanelConfig()
if (args.enable) {
delete cfg.accessPassword
delete cfg.mustChangePassword
cfg.ignoreRisk = true
_sessions.clear()
} else {
delete cfg.ignoreRisk
}
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
invalidateConfigCache()
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
if (cmd === 'auth_logout') {
const cookies = parseCookies(req)
if (cookies.clawpanel_session) _sessions.delete(cookies.clawpanel_session)
res.setHeader('Set-Cookie', 'clawpanel_session=; Path=/; HttpOnly; Max-Age=0')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
// --- 认证中间件:非豁免接口必须校验 ---
if (!isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录', code: 'AUTH_REQUIRED' }))
return
}
const handler = handlers[cmd]
if (!handler) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` }))
return
}
try {
const args = await readBody(req)
const result = await handler(args)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (e) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: e.message || String(e) }))
}
}
export function devApiPlugin() {
let _inited = false
function ensureInit() {
if (_inited) return
_inited = true
_initApi()
}
return {
name: 'clawpanel-dev-api',
configureServer(server) {
console.log('[dev-api] 开发 API 已启动,配置目录:', OPENCLAW_DIR)
console.log('[dev-api] 平台:', isMac ? 'macOS' : process.platform)
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/__api/')) return next()
const cmd = req.url.slice(7).split('?')[0]
const handler = handlers[cmd]
if (!handler) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` }))
return
}
try {
const args = await readBody(req)
const result = await handler(args)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (e) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: e.message || String(e) }))
}
})
}
ensureInit()
server.middlewares.use(_apiMiddleware)
},
configurePreviewServer(server) {
ensureInit()
server.middlewares.use(_apiMiddleware)
},
}
}

View File

@@ -140,6 +140,10 @@ install_clawpanel() {
cd "$INSTALL_DIR"
npm install
fi
# 生产构建(生成优化后的静态文件)
echo "📦 构建生产版本..."
cd "$INSTALL_DIR"
npx vite build
echo "✅ ClawPanel 安装完成: $INSTALL_DIR"
}
@@ -164,7 +168,7 @@ After=network.target
Type=simple
User=$(whoami)
WorkingDirectory=$INSTALL_DIR
ExecStart=$(which npx) vite --port $PANEL_PORT --host 0.0.0.0
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -185,7 +189,7 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
ExecStart=$(which npx) vite --port $PANEL_PORT --host 0.0.0.0
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -210,6 +214,32 @@ get_local_ip() {
echo "localhost"
}
# 生成默认访问密码
setup_default_password() {
local config_dir="$HOME/.openclaw"
local config_file="$config_dir/clawpanel.json"
mkdir -p "$config_dir"
# 已存在配置且有密码则跳过
if [ -f "$config_file" ]; then
local existing_pw=$(grep -o '"accessPassword"[[:space:]]*:[[:space:]]*"[^"]*"' "$config_file" | head -1)
if [ -n "$existing_pw" ]; then
echo " 已有访问密码,跳过生成"
DEFAULT_PASSWORD=""
return
fi
fi
DEFAULT_PASSWORD="123456"
cat > "$config_file" <<EOF
{
"accessPassword": "123456",
"mustChangePassword": true
}
EOF
echo "✅ 已设置默认访问密码: 123456"
}
# 主流程
main() {
detect_os
@@ -218,6 +248,7 @@ main() {
install_node
install_openclaw
install_clawpanel
setup_default_password
setup_systemd
local ip=$(get_local_ip)
@@ -236,6 +267,11 @@ main() {
echo " 🌐 访问地址: http://${ip}:${PANEL_PORT}"
echo " 📁 安装目录: $INSTALL_DIR"
echo " 📋 配置目录: $HOME/.openclaw/"
if [ -n "$DEFAULT_PASSWORD" ]; then
echo ""
echo " 🔑 默认访问密码: $DEFAULT_PASSWORD"
echo " ⚠️ 首次登录后会要求修改密码,请妥善保管新密码!"
fi
echo ""
echo " 常用命令:"
echo " $ctl_cmd status clawpanel # 查看状态"

77
scripts/sync-version.js Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* 版本号同步脚本
* 以 package.json 为唯一版本源,同步到所有相关文件
*
* 用法:
* node scripts/sync-version.js # 同步当前版本
* node scripts/sync-version.js 0.6.0 # 先改 package.json 再同步
*/
import { readFileSync, writeFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '..')
// 读取 package.json
const pkgPath = resolve(root, 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
// 如果传入了新版本号,先更新 package.json
const newVersion = process.argv[2]
if (newVersion) {
if (!/^\d+\.\d+\.\d+/.test(newVersion)) {
console.error('❌ 版本号格式不对,应为 x.y.z')
process.exit(1)
}
pkg.version = newVersion
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
console.log(`✅ package.json → ${newVersion}`)
}
const version = pkg.version
// 同步目标文件
const targets = [
{
file: 'src-tauri/tauri.conf.json',
update(content) {
const obj = JSON.parse(content)
obj.version = version
return JSON.stringify(obj, null, 2) + '\n'
},
},
{
file: 'src-tauri/Cargo.toml',
update(content) {
return content.replace(/^version\s*=\s*"[^"]*"/m, `version = "${version}"`)
},
},
{
file: 'docs/index.html',
update(content) {
return content.replace(/"softwareVersion":\s*"[^"]*"/, `"softwareVersion": "${version}"`)
},
},
]
let changed = 0
for (const { file, update } of targets) {
const filepath = resolve(root, file)
try {
const before = readFileSync(filepath, 'utf8')
const after = update(before)
if (before !== after) {
writeFileSync(filepath, after)
console.log(`${file}${version}`)
changed++
} else {
console.log(` ${file} — 已是 ${version}`)
}
} catch (e) {
console.error(`${file}: ${e.message}`)
}
}
console.log(`\n版本 ${version}${changed ? `已同步 ${changed} 个文件` : '所有文件已是最新'}`)