🎨 Sync 2026-04-10 15:34:26

This commit is contained in:
github-actions[bot] committed 2026-04-10 15:34:26 +08:00
1 parent 9b1a57d792
commit 42174fc0b0
66 files changed
+6841 -2210

No files matched your search

+184 -19
View File
@@ -77,8 +77,20 @@ rm -f "$_tmpf"
patch_iframe_headers() {
# 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入
# v2026.3.8: 资产路径可能通过符号链接解析,需同时搜索 OC_GLOBAL 和 NODE_BASE
local gw_js
# v2026.4.x: 安全头设置迁移到 server.impl-*.js 文件
local patched=0
# 1. 搜索 server.impl-*.js (v2026.4.x 新位置)
for f in $(find "${OC_GLOBAL}/lib/node_modules/openclaw/dist" -name "server.impl-*.js" -type f 2>/dev/null); do
if grep -q "X-Frame-Options.*DENY\|frame-ancestors.*none" "$f" 2>/dev/null; then
sed -i 's|res\.setHeader("X-Frame-Options", "DENY")|res.setHeader("X-Frame-Options", "ALLOW-FROM *") // patched by luci-app-openclaw|g' "$f"
sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f"
logger -t openclaw "Patched iframe headers in $f"
patched=1
fi
done
# 2. 兼容旧版本: 搜索 gateway-cli-*.js (v2026.3.x)
for search_root in "${OC_GLOBAL}" "${NODE_BASE}/lib"; do
[ -d "$search_root" ] || continue
for f in $(find "$search_root" -name "gateway-cli-*.js" -type f 2>/dev/null); do
@@ -86,14 +98,17 @@ for search_root in "${OC_GLOBAL}" "${NODE_BASE}/lib"; do
sed -i "s|res.setHeader(\"X-Frame-Options\", \"DENY\");|// res.setHeader(\"X-Frame-Options\", \"DENY\"); // patched by luci-app-openclaw|g" "$f"
sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f"
logger -t openclaw "Patched iframe headers in $f"
patched=1
fi
done
done
[ $patched -eq 1 ] && logger -t openclaw "iframe headers patched successfully"
}
sync_uci_to_json() {
# 将 UCI 配置同步到 openclaw.json,同时确保 token 双向同步
local port bind token
local port bind token json_token
port=$(uci -q get openclaw.main.port || echo "18789")
bind=$(uci -q get openclaw.main.bind || echo "lan")
token=$(uci -q get openclaw.main.token || echo "")
@@ -104,14 +119,28 @@ if [ ! -f "$CONFIG_FILE" ]; then
echo '{}' > "$CONFIG_FILE"
fi
# UCI 没有 token 时,尝试从已有的 JSON 读取
if [ -z "$token" ] && [ -x "$NODE_BIN" ]; then
token=$("$NODE_BIN" -e "
# 尝试从 JSON 读取 token (用于双向同步检测)
if [ -x "$NODE_BIN" ]; then
json_token=$("$NODE_BIN" -e "
try{const d=JSON.parse(require('fs').readFileSync('${CONFIG_FILE}','utf8'));
if(d.gateway&&d.gateway.auth&&d.gateway.auth.token)process.stdout.write(d.gateway.auth.token);}catch(e){}
" 2>/dev/null)
fi
# v2026.4.10: 双向同步策略 - JSON 中的 token 优先
# 原因: doctor --fix 或其他操作可能修改 JSON 中的 token
# 如果 JSON 有 token 且与 UCI 不同,以 JSON 为准
if [ -n "$json_token" ] && [ "$json_token" != "$token" ]; then
token="$json_token"
logger -t openclaw "Token 同步: JSON -> UCI"
fi
# UCI 和 JSON 都没有 token 时,生成一个新的
if [ -z "$token" ]; then
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
logger -t openclaw "Token 生成: 新 token"
fi
# 如果仍然没有 token,生成一个新的
if [ -z "$token" ]; then
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
@@ -162,6 +191,37 @@ d.update.checkOnStart=false;
if(d.models&&d.models.providers&&d.models.providers.ollama){const ol=d.models.providers.ollama;if(ol.api==='openai-chat-completions'||ol.api==='openai-completions')ol.api='ollama';if(ol.baseUrl&&ol.baseUrl.endsWith('/v1'))ol.baseUrl=ol.baseUrl.replace(/\/v1$/,'');if(ol.apiKey==='ollama')ol.apiKey='ollama-local';}
// v2026.3.7: 清理已废弃的顶层配置键 (loadConfig() 现在会严格校验)
['cli','commands.native','commands.nativeSkills','commands.ownerDisplay'].forEach(k=>{const ks=k.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]])return;o=o[ks[i]];}delete o[ks[ks.length-1]];});
// v2026.4.5: 清理已废弃的配置别名 (BREAKING CHANGE)
// 1. talk.* 命名空间废弃 (voiceId, apiKey)
if(d.talk){delete d.talk.voiceId;delete d.talk.apiKey;}
// 2. browser.ssrfPolicy.allowPrivateNetwork 废弃
if(d.browser&&d.browser.ssrfPolicy){delete d.browser.ssrfPolicy.allowPrivateNetwork;}
// 3. hooks.internal.handlers 废弃
if(d.hooks&&d.hooks.internal){delete d.hooks.internal.handlers;}
// 4. channel/group/room 的 allow 字段迁移到 enabled
['channel','group','room'].forEach(k=>{if(d[k]){Object.keys(d[k]).forEach(id=>{if(d[k][id]&&typeof d[k][id].allow!=='undefined'){d[k][id].enabled=d[k][id].allow;delete d[k][id].allow;}});}});
// 5. agents.defaults.cliBackends 废弃
if(d.agents&&d.agents.defaults){delete d.agents.defaults.cliBackends;}
// v2026.4.9: 自动同步 plugins.allow (解决 "plugins.allow is empty" 警告)
// 扫描已安装的插件并添加到 allow 列表
if(!d.plugins)d.plugins={};
if(!Array.isArray(d.plugins.allow))d.plugins.allow=[];
// 从 plugins.installs 中提取已安装插件
// 注意: 使用 installPath 中的目录名作为插件 ID (与 openclaw.plugin.json 中的 id 一致)
if(d.plugins.installs){
Object.keys(d.plugins.installs).forEach(key=>{
const inst=d.plugins.installs[key];
if(inst&&inst.installPath){
// 从 installPath 提取插件目录名 (如 openclaw-weixin)
const match=inst.installPath.match(/\/([^\/]+)\/?$/);
if(match&&match[1]&&!d.plugins.allow.includes(match[1])){
d.plugins.allow.push(match[1]);
}
}
});
}
// 始终信任 copilot-proxy (内置插件)
if(!d.plugins.allow.includes('copilot-proxy'))d.plugins.allow.push('copilot-proxy');
fs.writeFileSync(f,JSON.stringify(d,null,2));
" 2>/dev/null
fi
@@ -252,6 +312,98 @@ fi
# 修复数据目录权限 (防止 root 用户操作后留下无法读取的文件)
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
# v2026.4.9: 修复插件目录权限 (OpenClaw 要求插件目录属主为 root)
# 详见: https://github.com/nicepkg/openclaw/releases/tag/v2026.4.9
local ext_dir="${OC_DATA}/.openclaw/extensions"
if [ -d "$ext_dir" ]; then
chown -R root:root "$ext_dir" 2>/dev/null || true
fi
# v2026.4.x: 清理 jiti 缓存目录 (修复微信插件加载权限问题)
# jiti 编译 TypeScript 时会在 /tmp/jiti 创建缓存,如果由 root 创建则 openclaw 用户无法写入
rm -rf /tmp/jiti 2>/dev/null || true
# v2026.4.5: 执行 OpenClaw 官方配置迁移工具
# 自动迁移遗留配置到新版本格式
_run_config_migration() {
local oc_entry="$1"
[ -z "$oc_entry" ] && return
logger -t openclaw "执行配置迁移 (doctor --fix)..."
OPENCLAW_HOME="$OC_DATA" OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
"$NODE_BIN" "$oc_entry" doctor --fix 2>/dev/null && \
logger -t openclaw "配置迁移完成" || \
logger -t openclaw "配置迁移失败,请手动检查"
}
_run_config_migration "$oc_entry"
# doctor --fix 后再次修复权限 (防止 root 创建的文件导致 EACCES)
# 注意: extensions 目录保持 root 权限 (OpenClaw 安全要求)
find "$OC_DATA" -user root ! -path "${OC_DATA}/.openclaw/extensions*" -exec chown openclaw:openclaw {} \; 2>/dev/null || true
# v2026.4.10: doctor --fix 后确保关键配置字段存在
# doctor --fix 可能会删除或遗漏某些必要字段,需要补充
_ensure_critical_config() {
[ ! -f "$CONFIG_FILE" ] && return
[ ! -x "$NODE_BIN" ] && return
"$NODE_BIN" -e "
const fs = require('fs');
const f = '${CONFIG_FILE}';
let d = {};
try { d = JSON.parse(fs.readFileSync(f, 'utf8')); } catch(e) {}
// 确保 gateway.mode 存在
if (!d.gateway) d.gateway = {};
if (!d.gateway.mode) d.gateway.mode = 'local';
// 确保端口和绑定配置
if (!d.gateway.port) d.gateway.port = 18789;
if (!d.gateway.bind) d.gateway.bind = 'lan';
// 确保 controlUi 安全配置存在 (允许非 loopback 绑定和 LuCI iframe 嵌入)
if (!d.gateway.controlUi) d.gateway.controlUi = {};
if (d.gateway.controlUi.allowInsecureAuth !== true) d.gateway.controlUi.allowInsecureAuth = true;
if (d.gateway.controlUi.dangerouslyDisableDeviceAuth !== true) d.gateway.controlUi.dangerouslyDisableDeviceAuth = true;
if (d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback !== true) d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback = true;
// 确保认证配置
if (!d.gateway.auth) d.gateway.auth = {};
if (!d.gateway.auth.mode) d.gateway.auth.mode = 'token';
// 禁用 ACP dispatch (路由器内存有限)
if (!d.acp) d.acp = {};
if (!d.acp.dispatch) d.acp.dispatch = {};
d.acp.dispatch.enabled = false;
// 工具配置
if (!d.tools) d.tools = {};
d.tools.profile = 'coding';
// 禁用更新检查
if (!d.update) d.update = {};
d.update.checkOnStart = false;
fs.writeFileSync(f, JSON.stringify(d, null, 2));
" 2>/dev/null
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
}
_ensure_critical_config
# v2026.4.10: 最终权限修复 - 确保 Gateway 启动前所有数据目录权限正确
# doctor --fix 或 _ensure_critical_config 可能创建新的目录/文件
# 关键: agents 目录下的 agent 子目录必须可被 openclaw 用户写入
find "${OC_STATE_DIR}" -user root -type d -exec chown openclaw:openclaw {} \; 2>/dev/null || true
find "${OC_STATE_DIR}" -user root -type f -exec chown openclaw:openclaw {} \; 2>/dev/null || true
# 特别确保 agents/main/agent 目录权限 (模型配置写入目录)
local agent_dir="${OC_STATE_DIR}/agents/main/agent"
if [ -d "$agent_dir" ]; then
chown openclaw:openclaw "$agent_dir" 2>/dev/null || true
chmod 755 "$agent_dir" 2>/dev/null || true
fi
# extensions 目录除外 - 必须保持 root 权限 (OpenClaw 安全要求)
if [ -d "${OC_STATE_DIR}/extensions" ]; then
chown -R root:root "${OC_STATE_DIR}/extensions" 2>/dev/null || true
fi
# Patch iframe 安全头,允许 LuCI 嵌入
patch_iframe_headers
@@ -305,19 +457,32 @@ _ensure_port_free() {
_ensure_port_free "$port"
# 启动 OpenClaw Gateway (主服务, 前台运行)
procd_open_instance "gateway"
procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \
--port "$port" --bind "$gw_bind"
procd_set_param env \
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
OC_DATA="$OC_DATA" \
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# v2026.4.7/4.9: 环境变量安全验证 (阻止危险变量注入)
_validate_path() {
local p="$1"
case "$p" in
*'$'*|*'`'*|*'|'*|*'&'*) return 1 ;;
/proc/*|/sys/*|/dev/*) return 1 ;;
*) return 0 ;;
esac
}
for _dir in "$NODE_BASE" "$OC_GLOBAL" "$OC_DATA"; do
_validate_path "$_dir" || { logger -t openclaw "安全警告: 路径验证失败: $_dir"; return 1; }
done
procd_open_instance "gateway"
procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \
--port "$port" --bind "$gw_bind"
procd_set_param env \
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
OC_DATA="$OC_DATA" \
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
procd_set_param user openclaw
procd_set_param respawn 3600 10 5
procd_set_param stdout 1
@@ -90,4 +90,16 @@ if [ -z "$CURRENT_PTY_TOKEN" ]; then
uci commit openclaw
fi
# 启用服务自启动 (创建 /etc/rc.d/S99openclaw 符号链接)
# 这一步确保安装/升级后服务能随系统启动
# 注意: 仅当 UCI 中 enabled='1' 或尚未设置 enabled 时才启用
# 如果用户明确设置 enabled='0' (禁用),则尊重用户选择
if [ -x /etc/init.d/openclaw ]; then
OC_ENABLED=$(uci -q get openclaw.main.enabled)
# enabled='1' 或未设置时启用自启动;enabled='0' 时不操作
if [ "$OC_ENABLED" != "0" ]; then
/etc/init.d/openclaw enable 2>/dev/null
fi
fi
exit 0
+5 -14
View File
@@ -19,7 +19,7 @@ NODE_VERSION_V1="22.15.1"
# 默认使用 V2 版本 (可通过 NODE_VERSION 环境变量覆盖)
NODE_VERSION="${NODE_VERSION:-${NODE_VERSION_V2}}"
# 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试)
OC_TESTED_VERSION="2026.3.28"
OC_TESTED_VERSION="2026.4.9"
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
OC_VERSION="${OC_VERSION:-}"
@@ -375,18 +375,6 @@ install_openclaw() {
log_error "OpenClaw 安装验证失败"
exit 1
fi
# 安装 Gemini CLI (官方模型配置向导的 Google Gemini OAuth 依赖)
if [ -x "$NPM_BIN" ]; then
echo ""
echo "=== 安装 Gemini CLI (Google OAuth 依赖) ==="
"$NPM_BIN" install -g @google/gemini-cli --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -3
if command -v gemini >/dev/null 2>&1 || [ -x "$OC_GLOBAL/bin/gemini" ]; then
log_info "Gemini CLI 安装成功"
else
log_warn "Gemini CLI 安装失败 (不影响核心功能,仅影响 Google Gemini OAuth 登录)"
fi
fi
}
init_openclaw() {
@@ -635,7 +623,10 @@ do_factory_reset() {
log_info "新认证令牌: $new_token"
fi
# 7. 重启服务
# 7. 确保数据目录权限正确 (防止 root 操作留下的文件)
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
# 8. 重启服务
/etc/init.d/openclaw start >/dev/null 2>&1 &
log_info "出厂设置已恢复,Gateway 重启中..."
}
File diff suppressed because it is too large. Load diff
@@ -8,6 +8,10 @@
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
# ── 交互式菜单引擎路径 ──
OC_MENU_ENGINE="/usr/share/openclaw/oc-menu-engine.js"
OC_INTERACTIVE="/usr/share/openclaw/oc-config-interactive.js"
# ── 端口检查兼容函数 (ss 或 netstat) ──
# check_port_listening <port> — 检查端口是否在监听,返回 0/1
check_port_listening() {
@@ -200,10 +204,21 @@ enable_auth_plugins() {
const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8'));
if(!d.plugins)d.plugins={};if(!d.plugins.entries)d.plugins.entries={};
const e=d.plugins.entries;
['qwen-portal-auth','copilot-proxy','google-gemini-cli-auth','minimax-portal-auth'].forEach(p=>{
// 启用有效的认证插件
['copilot-proxy'].forEach(p=>{
if(!e[p])e[p]={};e[p].enabled=true;
});
// 清理已废弃或未安装的插件配置 (v2026.4.9: 这些插件已移除或更名)
delete e['qwen-portal-auth'];
delete e['google-gemini-cli-auth'];
delete e['minimax-portal-auth'];
delete e['google-antigravity-auth'];
delete e['openclaw-weixin']; // 微信插件有独立安装流程,不应在 entries 中
// 清理过时的 installs 配置
if(d.plugins && d.plugins.installs){
delete d.plugins.installs['google-gemini-cli-auth'];
delete d.plugins.installs['minimax-portal-auth'];
}
fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2));
}catch(e){}
" 2>/dev/null
@@ -641,45 +656,48 @@ show_current_config() {
# ══════════════════════════════════════════════════════════════
configure_model() {
echo ""
echo -e " ${BOLD}🤖 配置 AI 模型提供商${NC}"
echo -e " ${BOLD}🤖 配置 AI 模型和提供商${NC}"
echo ""
echo -e " ${GREEN}${BOLD}--- 推荐 ---${NC}"
echo -e " ${CYAN}1)${NC} 🌟 官方完整模型配置向导 ${GREEN}(推荐,支持所有提供商)${NC}"
echo -e " ${GREEN}${BOLD}🌟 ── 推荐 ──${NC}"
echo -e " ${CYAN}w)${NC} 🌟 官方完整模型配置向导 ${GREEN}(推荐,支持所有提供商)${NC}"
echo ""
echo -e " ${BOLD}--- 快速配置 ---${NC}"
echo -e " ${CYAN}2)${NC} OpenAI (GPT-5.2, GPT-5 mini, GPT-4.1)"
echo -e " ${CYAN}3)${NC} Anthropic (Claude Sonnet 4, Opus 4, Haiku)"
echo -e " ${CYAN}4)${NC} Google Gemini (Gemini 2.5 Pro/Flash, Gemini 3)"
echo -e " ${CYAN}5)${NC} OpenRouter (聚合多家模型)"
echo -e " ${CYAN}6)${NC} DeepSeek (DeepSeek-V3/R1)"
echo -e " ${CYAN}7)${NC} GitHub Copilot (需要 Copilot 订阅)"
echo -e " ${CYAN}8)${NC} 阿里云通义千问 Qwen (Portal/API/Coding Plan)"
echo -e " ${CYAN}9)${NC} xAI Grok (Grok-3/3-mini)"
echo -e " ${CYAN}10)${NC} Groq (Llama 4, Llama 3.3)"
echo -e " ${CYAN}11)${NC} 硅基流动 SiliconFlow"
echo -e " ${CYAN}12)${NC} Ollama (本地模型,无需 API Key)"
echo -e " ${CYAN}13)${NC} 腾讯云 Coding Plan (HY T1/TurboS/GLM-5/Kimi)"
echo -e " ${CYAN}14)${NC} Mistral AI (Mistral Large, Codestral)"
echo -e " ${CYAN}15)${NC} 百度千帆 (ERNIE-4.0, ERNIE-3.5)"
echo -e " ${CYAN}16)${NC} 自定义 OpenAI 兼容 API"
echo -e " ${CYAN}0)${NC} 返回"
echo -e " ${BOLD}🌍 ── 国外模型提供商 ──${NC}"
echo -e " ${CYAN}a)${NC} OpenAI (GPT-5.2, GPT-5 mini, GPT-4.1)"
echo -e " ${CYAN}b)${NC} Anthropic (Claude Sonnet 4, Opus 4, Haiku)"
echo -e " ${CYAN}c)${NC} Google Gemini (Gemini 2.5 Pro/Flash, Gemini 3)"
echo -e " ${CYAN}d)${NC} OpenRouter (聚合多家模型)"
echo -e " ${CYAN}e)${NC} GitHub Copilot (需要 Copilot 订阅)"
echo -e " ${CYAN}f)${NC} xAI Grok (Grok-4/3)"
echo ""
prompt_with_default "请选择" "1" choice
echo -e " ${BOLD}🇨🇳 ── 国内模型提供商 ──${NC}"
echo -e " ${CYAN}g)${NC} 阿里云通义千问 Qwen (Portal/API/Coding Plan)"
echo -e " ${CYAN}h)${NC} 硅基流动 SiliconFlow"
echo -e " ${CYAN}i)${NC} 腾讯云 Coding Plan (HY T1/TurboS/GLM-5/Kimi)"
echo -e " ${CYAN}j)${NC} 百度千帆 (ERNIE-4.0, ERNIE-3.5)"
echo -e " ${CYAN}k)${NC} 智谱 GLM / Z.AI (GLM-5.1, GLM-5)"
echo ""
echo -e " ${BOLD}🏠 ── 本地模型 / 自定义 API ──${NC}"
echo -e " ${CYAN}l)${NC} Ollama (本地模型,无需 API Key)"
echo -e " ${CYAN}m)${NC} 自定义 OpenAI 兼容 API"
echo ""
echo -e " ${CYAN}q)${NC} 返回"
echo ""
prompt_with_default "请选择" "w" choice
case "$choice" in
1)
w)
echo ""
echo -e " ${CYAN}启动官方完整模型配置向导...${NC}"
echo -e " ${YELLOW}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${NC}"
echo ""
echo -e " ${CYAN}预启用模型认证插件...${NC}"
echo -e " ${CYAN}清理过时插件配置...${NC}"
enable_auth_plugins
echo ""
(oc_cmd configure --section model) || echo -e " ${YELLOW}配置向导已退出${NC}"
echo ""
ask_restart
;;
2)
a)
echo ""
echo -e " ${BOLD}OpenAI 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://platform.openai.com/api-keys${NC}"
@@ -712,7 +730,7 @@ configure_model() {
echo -e " ${GREEN}✅ OpenAI 已配置,活跃模型: openai/${model_name}${NC}"
fi
;;
3)
b)
echo ""
echo -e " ${BOLD}Anthropic 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://console.anthropic.com/settings/keys${NC}"
@@ -743,7 +761,7 @@ configure_model() {
echo -e " ${GREEN}✅ Anthropic 已配置,活跃模型: anthropic/${model_name}${NC}"
fi
;;
4)
c)
echo ""
echo -e " ${BOLD}Google Gemini 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://aistudio.google.com/apikey${NC}"
@@ -774,7 +792,7 @@ configure_model() {
echo -e " ${GREEN}✅ Google Gemini 已配置,活跃模型: google/${model_name}${NC}"
fi
;;
5)
d)
echo ""
echo -e " ${BOLD}OpenRouter 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://openrouter.ai/keys${NC}"
@@ -808,33 +826,7 @@ configure_model() {
echo -e " ${GREEN}✅ OpenRouter 已配置,活跃模型: openrouter/${model_name}${NC}"
fi
;;
6)
echo ""
echo -e " ${BOLD}DeepSeek 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://platform.deepseek.com/api_keys${NC}"
echo ""
prompt_with_default "请输入 DeepSeek API Key" "" api_key
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} deepseek-chat — DeepSeek-V3 (通用对话)"
echo -e " ${CYAN}b)${NC} deepseek-reasoner — DeepSeek-R1 (深度推理)"
echo -e " ${CYAN}c)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="deepseek-chat" ;;
b) model_name="deepseek-reasoner" ;;
c) prompt_with_default "请输入模型名称" "deepseek-chat" model_name ;;
*) model_name="deepseek-chat" ;;
esac
auth_set_apikey deepseek "$api_key"
register_custom_provider deepseek "https://api.deepseek.com/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "deepseek/${model_name}"
echo -e " ${GREEN}✅ DeepSeek 已配置,活跃模型: deepseek/${model_name}${NC}"
fi
;;
7)
e)
echo ""
echo -e " ${BOLD}GitHub Copilot 配置${NC}"
echo -e " ${YELLOW}需要有效的 GitHub Copilot 订阅 (Free/Pro/Business 均可)${NC}"
@@ -889,7 +881,7 @@ configure_model() {
echo -e " ${YELLOW}OAuth 授权已退出或失败${NC}"
fi
;;
8)
g)
echo ""
echo -e " ${BOLD}阿里云通义千问 Qwen 配置${NC}"
echo ""
@@ -1016,7 +1008,7 @@ configure_model() {
;;
esac
;;
9)
f)
echo ""
echo -e " ${BOLD}xAI Grok 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://console.x.ai${NC}"
@@ -1049,41 +1041,7 @@ configure_model() {
echo -e " ${GREEN}✅ xAI Grok 已配置,活跃模型: xai/${model_name}${NC}"
fi
;;
10)
echo ""
echo -e " ${BOLD}Groq 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://console.groq.com/keys${NC}"
echo -e " ${YELLOW}Groq 提供超快推理速度${NC}"
echo ""
prompt_with_default "请输入 Groq API Key" "" api_key
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} meta-llama/llama-4-maverick-17b-128e-instruct — Llama 4 Maverick (推荐)"
echo -e " ${CYAN}b)${NC} meta-llama/llama-4-scout-17b-16e-instruct — Llama 4 Scout"
echo -e " ${CYAN}c)${NC} moonshotai/kimi-k2-instruct — Kimi K2"
echo -e " ${CYAN}d)${NC} qwen/qwen3-32b — 通义千问 Qwen3 32B"
echo -e " ${CYAN}e)${NC} llama-3.3-70b-versatile — Llama 3.3 70B"
echo -e " ${CYAN}f)${NC} llama-3.1-8b-instant — Llama 3.1 8B (极速)"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="meta-llama/llama-4-maverick-17b-128e-instruct" ;;
b) model_name="meta-llama/llama-4-scout-17b-16e-instruct" ;;
c) model_name="moonshotai/kimi-k2-instruct" ;;
d) model_name="qwen/qwen3-32b" ;;
e) model_name="llama-3.3-70b-versatile" ;;
f) model_name="llama-3.1-8b-instant" ;;
g) prompt_with_default "请输入模型名称" "meta-llama/llama-4-maverick-17b-128e-instruct" model_name ;;
*) model_name="meta-llama/llama-4-maverick-17b-128e-instruct" ;;
esac
auth_set_apikey groq "$api_key"
register_and_set_model "groq/${model_name}"
echo -e " ${GREEN}✅ Groq 已配置,活跃模型: groq/${model_name}${NC}"
fi
;;
11)
h)
echo ""
echo -e " ${BOLD}硅基流动 SiliconFlow 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://cloud.siliconflow.cn/account/ak${NC}"
@@ -1130,7 +1088,7 @@ configure_model() {
echo -e " ${GREEN}✅ SiliconFlow 已配置,活跃模型: siliconflow/${model_name}${NC}"
fi
;;
12)
l)
echo ""
echo -e " ${BOLD}🦙 Ollama 本地模型配置${NC}"
echo -e " ${YELLOW}Ollama 在本地或局域网运行大模型,无需 API Key${NC}"
@@ -1246,7 +1204,7 @@ configure_model() {
fi
fi
;;
13)
i)
echo ""
echo -e " ${BOLD}腾讯云大模型 Coding Plan 套餐配置${NC}"
echo ""
@@ -1295,37 +1253,7 @@ configure_model() {
echo -e " ${DIM}提示: 套餐内全部模型已注册,可随时在 WebChat 中通过 /model 切换${NC}"
fi
;;
14)
echo ""
echo -e " ${BOLD}Mistral AI 配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://console.mistral.ai/api-keys/${NC}"
echo ""
prompt_with_default "请输入 Mistral API Key" "" api_key
if [ -n "$api_key" ]; then
auth_set_apikey mistral "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} mistral-large-latest — 旗舰模型,最强性能 (推荐)"
echo -e " ${CYAN}b)${NC} mistral-medium-latest — 均衡模型"
echo -e " ${CYAN}c)${NC} codestral-latest — 代码专用,极速补全"
echo -e " ${CYAN}d)${NC} mistral-small-latest — 轻量快速"
echo -e " ${CYAN}e)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="mistral-large-latest" ;;
b) model_name="mistral-medium-latest" ;;
c) model_name="codestral-latest" ;;
d) model_name="mistral-small-latest" ;;
e) prompt_with_default "请输入模型名称" "mistral-large-latest" model_name ;;
*) model_name="mistral-large-latest" ;;
esac
register_custom_provider mistral "https://api.mistral.ai/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "mistral/${model_name}"
echo -e " ${GREEN}✅ Mistral AI 已配置,活跃模型: mistral/${model_name}${NC}"
fi
;;
15)
j)
echo ""
echo -e " ${BOLD}百度千帆大模型配置${NC}"
echo -e " ${YELLOW}获取 API Key: https://console.bce.baidu.com/qianfan/ais/console/onlineService${NC}"
@@ -1359,7 +1287,70 @@ configure_model() {
echo -e " ${GREEN}✅ 百度千帆已配置,活跃模型: qianfan/${model_name}${NC}"
fi
;;
16)
k)
echo ""
echo -e " ${BOLD}智谱 GLM / Z.AI 配置${NC}"
echo ""
echo -e " ${CYAN}认证方式:${NC}"
echo -e " ${CYAN}a)${NC} CN (open.bigmodel.cn) ${GREEN}★ 国内用户推荐${NC}"
echo -e " ${CYAN}b)${NC} Coding-Plan-CN (智谱 Coding Plan 套餐)"
echo -e " ${CYAN}c)${NC} Global (api.z.ai)"
echo -e " ${CYAN}d)${NC} 手动输入 API Key"
echo ""
prompt_with_default "请选择认证方式" "a" zai_method
case "$zai_method" in
a)
zai_base_url="https://open.bigmodel.cn/api/paas/v4"
echo -e " ${YELLOW}获取 API Key: https://open.bigmodel.cn/api-key${NC}"
;;
b)
zai_base_url="https://open.bigmodel.cn/api/coding/paas/v4"
echo -e " ${YELLOW}Coding Plan 套餐 API Key (sk-...)${NC}"
;;
c)
zai_base_url="https://api.z.ai/api/paas/v4"
echo -e " ${YELLOW}全球版 API Key${NC}"
;;
d)
prompt_with_default "请输入 Base URL" "https://open.bigmodel.cn/api/paas/v4" zai_base_url
;;
*)
zai_base_url="https://open.bigmodel.cn/api/paas/v4"
;;
esac
echo ""
prompt_with_default "请输入智谱 API Key" "" api_key
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} glm-5.1 — GLM-5.1 (推荐)"
echo -e " ${CYAN}b)${NC} glm-5 — GLM-5"
echo -e " ${CYAN}c)${NC} glm-4.7 — GLM-4.7"
echo -e " ${CYAN}d)${NC} glm-4.7-flash — GLM-4.7 Flash"
echo -e " ${CYAN}e)${NC} glm-4.5 — GLM-4.5"
echo -e " ${CYAN}f)${NC} glm-4.5-flash — GLM-4.5 Flash (免费额度)"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="glm-5.1" ;;
b) model_name="glm-5" ;;
c) model_name="glm-4.7" ;;
d) model_name="glm-4.7-flash" ;;
e) model_name="glm-4.5" ;;
f) model_name="glm-4.5-flash" ;;
g) prompt_with_default "请输入模型名称" "glm-5.1" model_name ;;
*) model_name="glm-5.1" ;;
esac
# 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持)
auth_set_apikey zai "$api_key"
register_custom_provider zai "$zai_base_url" "$api_key" "$model_name" "$model_name" "128000" "4096"
register_and_set_model "zai/${model_name}"
echo -e " ${GREEN}✅ 智谱 GLM 已配置,活跃模型: zai/${model_name}${NC}"
echo -e " ${DIM} Base URL: ${zai_base_url}${NC}"
fi
;;
m)
echo ""
echo -e " ${BOLD}自定义 OpenAI 兼容 API${NC}"
echo -e " ${YELLOW}支持任何兼容 OpenAI API 格式的服务商${NC}"
@@ -1374,7 +1365,7 @@ configure_model() {
echo -e " ${GREEN}✅ 自定义模型已配置,活跃模型: openai-compatible/${model_name}${NC}"
fi
;;
0) return ;;
q) return ;;
esac
if [ "$choice" != "0" ] && [ "$choice" != "1" ]; then
@@ -2227,7 +2218,7 @@ reset_to_defaults() {
echo -e " ${CYAN}已取消${NC}"
fi
;;
4)
c)
echo ""
echo -e " ${RED}╔══════════════════════════════════════════════════════╗${NC}"
echo -e " ${RED}║ ⚠️ 完全恢复出厂设置 ║${NC}"
@@ -2409,7 +2400,7 @@ backup_restore_menu() {
oc_cmd backup verify "$latest" 2>&1
fi
;;
4)
c)
echo ""
if [ -d "$backup_dir" ]; then
local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | wc -l)
@@ -2427,7 +2418,7 @@ backup_restore_menu() {
echo ""
echo -e " ${DIM}备份目录: ${backup_dir}${NC}"
;;
5)
d)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}"
@@ -2479,7 +2470,60 @@ backup_restore_menu() {
esac
}
# ══════════════════════════════════════════════════════════════════════════
# 交互式菜单 (方向键导航)
# ══════════════════════════════════════════════════════════════════════════
# 检测是否支持交互模式 (需要 Node.js + TTY 终端)
can_use_interactive() {
[ -x "$NODE_BIN" ] || return 1
[ -f "$OC_INTERACTIVE" ] || return 1
[ -f "$OC_MENU_ENGINE" ] || return 1
# 检测是否为真实 TTY (排除某些非交互环境)
[ -t 0 ] && [ -t 1 ] || return 1
return 0
}
# 启动交互式菜单 (方向键导航版本)
launch_interactive_menu() {
if ! can_use_interactive; then
echo -e " ${YELLOW}⚠️ 当前环境不支持交互模式,使用传统菜单${NC}"
return 1
fi
# 调用 Node.js 交互式前端
"$NODE_BIN" "$OC_INTERACTIVE" 2>&1
local rc=$?
# 返回后刷新配置权限
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
return $rc
}
# 启动交互式模型配置
launch_interactive_model_config() {
if ! can_use_interactive; then
echo -e " ${YELLOW}⚠️ 当前环境不支持交互模式,使用传统菜单${NC}"
configure_model # 回退到传统菜单
return $?
fi
# 调用 Node.js 交互式前端的模型配置模块
"$NODE_BIN" "$OC_INTERACTIVE" model 2>&1
local rc=$?
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
return $rc
}
main_menu() {
# 直接启动交互模式 (如果支持)
if can_use_interactive && [ "${OC_FORCE_TRADITIONAL:-0}" != "1" ]; then
launch_interactive_menu
return $?
fi
# 传统菜单 (回退方案)
while true; do
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
@@ -2693,6 +2737,29 @@ advanced_menu() {
# ── 支持命令行参数 ──
case "${1:-}" in
--tui|--interactive)
# 强制启动交互式菜单 (方向键导航)
if can_use_interactive; then
launch_interactive_menu
else
echo -e "${YELLOW}⚠️ 当前环境不支持交互模式${NC}"
echo "需要: Node.js + TTY 终端"
exit 1
fi
;;
--traditional)
# 强制使用传统菜单
export OC_FORCE_TRADITIONAL=1
main_menu
;;
--model)
# 直接进入模型配置
if can_use_interactive; then
launch_interactive_model_config
else
configure_model
fi
;;
--set)
if [ -n "${2:-}" ] && [ -n "${3:-}" ]; then
json_set "$2" "$3"
@@ -2727,12 +2794,21 @@ case "${1:-}" in
echo "OpenClaw AI Gateway — OpenWrt 配置管理工具"
echo ""
echo "用法:"
echo " oc-config.sh # 进入交互式菜单"
echo " oc-config.sh # 进入交互式菜单 (自动检测)"
echo " oc-config.sh --tui # 强制使用方向键交互模式"
echo " oc-config.sh --traditional # 强制使用传统数字菜单"
echo " oc-config.sh --model # 直接进入模型配置"
echo " oc-config.sh --set K V # 设置配置项"
echo " oc-config.sh --get K # 读取配置项"
echo " oc-config.sh --restart # 重启 Gateway"
echo " oc-config.sh --status # 查看状态"
echo ""
echo "交互模式快捷键:"
echo " ↑↓ 导航选项"
echo " 回车 确认选择"
echo " ESC/q 返回上级"
echo " 字符 搜索过滤"
echo ""
;;
"")
main_menu
@@ -0,0 +1,727 @@
/**
* ============================================================================
* OpenClaw 配置工具 — 交互式菜单引擎
* ============================================================================
*
* 功能特性:
* - 方向键 (↑↓) 导航菜单选项
* - 回车/空格 确认选择
* - Tab 在多列菜单中切换
* - ESC/q 返回上级菜单
* - 搜索过滤 (输入字符实时过滤)
* - 纯 Node.js 实现,零外部依赖
* - 完全兼容 xterm.js / Web PTY 环境
*
* 使用方式:
* const menu = require('./oc-menu-engine');
* const choice = await menu.select({
* title: '选择模型提供商',
* items: [
* { key: 'a', label: 'OpenAI', desc: 'GPT-5, GPT-4.1' },
* { key: 'b', label: 'Anthropic', desc: 'Claude' },
* ]
* });
*/
// ANSI 颜色代码
const C = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
italic: '\x1b[3m',
underline: '\x1b[4m',
// 前景色
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
// 亮色
brightRed: '\x1b[91m',
brightGreen: '\x1b[92m',
brightYellow: '\x1b[93m',
brightBlue: '\x1b[94m',
brightMagenta: '\x1b[95m',
brightCyan: '\x1b[96m',
// 背景色
bgBlack: '\x1b[40m',
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgBlue: '\x1b[44m',
bgMagenta: '\x1b[45m',
bgCyan: '\x1b[46m',
bgWhite: '\x1b[47m',
// 光标控制
hide: '\x1b[?25l',
show: '\x1b[?25h',
save: '\x1b[s',
restore: '\x1b[u',
clearLine: '\x1b[2K',
clearDown: '\x1b[J',
// 移动
up: (n = 1) => `\x1b[${n}A`,
down: (n = 1) => `\x1b[${n}B`,
right: (n = 1) => `\x1b[${n}C`,
left: (n = 1) => `\x1b[${n}D`,
toCol: (n) => `\x1b[${n}G`,
toRow: (n) => `\x1b[${n}H`,
to: (row, col) => `\x1b[${row};${col}H`,
};
// ═══════════════════════════════════════════════════════════════════════════
// 终端输入处理
// ═══════════════════════════════════════════════════════════════════════════
/**
* 设置终端为原始模式 (逐字节读取)
*/
function setRawMode(enable) {
if (process.stdin.isTTY) {
process.stdin.setRawMode(enable);
}
}
/**
* 从 stdin 读取单个按键
* 支持 Bracketed Paste Mode (粘贴检测)
* @returns {Promise<{name: string, sequence: string, char: string, paste?: string}>}
*/
function readKey() {
return new Promise((resolve) => {
const onData = (buffer) => {
process.stdin.off('data', onData);
const seq = buffer.toString('utf8');
// 解析按键
let key = { sequence: seq, char: '', name: '' };
// 检测 Bracketed Paste (粘贴操作)
// 终端在粘贴时发送: ESC[200~ <粘贴内容> ESC[201~
if (seq.startsWith('\x1b[200~') && seq.endsWith('\x1b[201~')) {
// 提取粘贴的内容 (去掉首尾的转义序列)
const pasteContent = seq.slice(6, -6);
key.name = 'paste';
key.paste = pasteContent;
key.char = '';
resolve(key);
return;
}
if (seq === '\r' || seq === '\n') {
key.name = 'return';
} else if (seq === '\t') {
key.name = 'tab';
} else if (seq === '\x1b') {
key.name = 'escape';
} else if (seq === '\x7f' || seq === '\x08') {
key.name = 'backspace';
} else if (seq === '\x1b[A') {
key.name = 'up';
} else if (seq === '\x1b[B') {
key.name = 'down';
} else if (seq === '\x1b[C') {
key.name = 'right';
} else if (seq === '\x1b[D') {
key.name = 'left';
} else if (seq === '\x1b[1;2A' || seq === '\x1b[1;2B') {
// Shift+Up/Down - 可用于快速滚动
key.name = seq.endsWith('A') ? 'shift-up' : 'shift-down';
} else if (seq === '\x1b[5~') {
key.name = 'pageup';
} else if (seq === '\x1b[6~') {
key.name = 'pagedown';
} else if (seq === '\x1b[H') {
key.name = 'home';
} else if (seq === '\x1b[F') {
key.name = 'end';
} else if (seq.length === 1 && seq >= ' ' && seq <= '~') {
key.char = seq;
key.name = seq.toLowerCase();
} else if (seq.startsWith('\x1b[') && seq.endsWith('~')) {
// 功能键 F1-F12
const code = seq.slice(2, -1);
if (code >= '11' && code <= '24') {
key.name = `f${parseInt(code) - 10}`;
}
}
resolve(key);
};
process.stdin.once('data', onData);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 交互式选择菜单
// ═══════════════════════════════════════════════════════════════════════════
// 记录上一次渲染的菜单行数,用于正确清除
let lastRenderLines = 0;
/**
* 重置渲染计数器 (在打印非菜单内容前调用)
*/
function resetRenderCount() {
lastRenderLines = 0;
}
/**
* 渲染菜单选项
* @param {Array} items 菜单项数组
* @param {number} selected 当前选中索引
* @param {string} searchFilter 搜索过滤字符串
* @param {object} options 配置选项
* @returns {Array} 过滤后的菜单项
*/
function renderMenu(items, selected, searchFilter = '', options = {}) {
const {
title = '请选择',
header = '',
footer = '',
showSearch = true,
showHelp = true,
maxWidth = 80,
} = options;
// 过滤项目
let filteredItems = items;
if (searchFilter) {
const filter = searchFilter.toLowerCase();
filteredItems = items.filter(item =>
(item.label || '').toLowerCase().includes(filter) ||
(item.desc || '').toLowerCase().includes(filter) ||
(item.key || '').toLowerCase().includes(filter)
);
}
// 输出缓冲
const lines = [];
// 标题
if (title) {
lines.push(`${C.bold}${C.cyan} ${title}${C.reset}`);
lines.push('');
}
// 头部信息
if (header) {
lines.push(` ${header}`);
lines.push('');
}
// 搜索框
if (showSearch && searchFilter !== undefined) {
const searchPrompt = searchFilter
? `${C.dim}搜索: ${C.reset}${C.yellow}${searchFilter}${C.reset}${C.dim}█${C.reset}`
: `${C.dim}搜索: ${C.reset}${C.dim}(输入字符过滤,ESC 清空)${C.reset}`;
lines.push(` ${searchPrompt}`);
lines.push('');
}
// 菜单项
filteredItems.forEach((item, idx) => {
const isSelected = idx === selected;
const isDisabled = item.disabled;
// 选中态样式 - 使用背景色高亮整行
const cursor = isSelected ? `${C.cyan}▸${C.reset} ` : ' ';
const labelStyle = isSelected ? `${C.bold}${C.cyan}` : (isDisabled ? C.dim : '');
const descStyle = isSelected ? C.cyan : C.dim;
// 构建行
const keyLabel = item.key ? `${C.yellow}${item.key}${C.reset}` : '';
const mainLabel = `${labelStyle}${item.label || ''}${C.reset}`;
const descLabel = item.desc ? `${descStyle}— ${item.desc}${C.reset}` : '';
// 拼接
let line = cursor;
if (keyLabel) line += `${C.dim}[${C.reset}${keyLabel}${C.dim}]${C.reset} `;
line += mainLabel;
if (descLabel && !isDisabled) line += ` ${descLabel}`;
lines.push(line);
});
// 空状态
if (filteredItems.length === 0) {
lines.push(` ${C.dim}(没有匹配的项目)${C.reset}`);
}
// 底部帮助
if (showHelp) {
lines.push('');
lines.push(` ${C.dim}↑↓ 导航 回车 确认 ESC/q 返回${showSearch ? ' 输入 搜索' : ''}${C.reset}`);
}
// 自定义底部
if (footer) {
lines.push('');
lines.push(` ${footer}`);
}
// 计算需要的清除行数
const totalLines = lines.length + 1;
// 清除之前的内容: 移动到开头,清除到屏幕底部
let clearSeq = '\r';
if (lastRenderLines > 0) {
// 移动到上一次渲染的第一行
clearSeq += `\x1b[${lastRenderLines}A`;
// 清除从此行到屏幕底部
clearSeq += C.clearDown;
}
lastRenderLines = totalLines;
// 输出: 清除旧内容 + 新内容
process.stdout.write(clearSeq + lines.join('\n') + '\n');
return filteredItems;
}
/**
* 交互式单选菜单
* @param {object} options 配置选项
* @returns {Promise<object|null>} 选中的项目或 null (取消)
*/
async function select(options = {}) {
const {
items = [],
title = '请选择',
header = '',
footer = '',
defaultIndex = 0,
showSearch = true,
allowCancel = true,
exitKeys = ['escape', 'q'],
onSelect = null, // 选择回调 (可用于预览)
} = options;
if (items.length === 0) {
return null;
}
// 重置渲染行计数
lastRenderLines = 0;
// 初始化状态
let selected = Math.min(defaultIndex, items.length - 1);
let searchFilter = '';
let filteredItems = items;
let done = false;
let result = null;
// 保存终端状态
setRawMode(true);
process.stdout.write(C.hide);
process.stdout.write(C.save);
try {
// 初始渲染
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
while (!done) {
const key = await readKey();
// 处理按键
if (key.name === 'return') {
// 确认选择
if (filteredItems.length > 0 && !filteredItems[selected].disabled) {
result = filteredItems[selected];
done = true;
}
} else if (key.name === 'up') {
// 上移
if (selected > 0) {
selected--;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.name === 'down') {
// 下移
if (selected < filteredItems.length - 1) {
selected++;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.name === 'pageup') {
// 上翻页
selected = Math.max(0, selected - 10);
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'pagedown') {
// 下翻页
selected = Math.min(filteredItems.length - 1, selected + 10);
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'home') {
selected = 0;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'end') {
selected = filteredItems.length - 1;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (exitKeys.includes(key.name) && allowCancel) {
// 取消/退出
done = true;
} else if (key.name === 'backspace') {
// 删除搜索字符
if (searchFilter.length > 0) {
searchFilter = searchFilter.slice(0, -1);
selected = 0; // 重置选中
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.char && key.char.length === 1) {
// 输入字符 -> 搜索
if (showSearch) {
searchFilter += key.char;
selected = 0;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
}
// 直接按键选择 (如果菜单项有 key 属性)
if (key.char && !showSearch) {
const matchByChar = items.find(item => (item.key || '').toLowerCase() === key.name);
if (matchByChar) {
result = matchByChar;
done = true;
}
}
}
} finally {
// 恢复终端状态
process.stdout.write(C.restore);
process.stdout.write(C.clearDown);
process.stdout.write(C.show);
setRawMode(false);
}
return result;
}
// ═══════════════════════════════════════════════════════════════════════════
// 其他交互组件
// ═══════════════════════════════════════════════════════════════════════════
/**
* 文本输入框
* 支持粘贴操作 (Bracketed Paste Mode)
* @param {object} options 配置选项
* @returns {Promise<string|null>} 输入的文本或 null (取消)
*/
async function input(options = {}) {
const {
prompt = '请输入',
defaultValue = '',
placeholder = '',
validate = null,
allowCancel = true,
hidden = false, // 密码输入模式
} = options;
let value = '';
let done = false;
let cancelled = false;
// 启用 Bracketed Paste Mode
process.stdout.write('\x1b[?2004h');
setRawMode(true);
process.stdout.write(C.hide);
try {
// 初始渲染
const renderInput = () => {
const displayValue = hidden ? '*'.repeat(value.length) : value;
const promptText = `${C.bold}${C.cyan}${prompt}:${C.reset} `;
const valueText = displayValue || `${C.dim}${placeholder}${C.reset}`;
process.stdout.write(`\r${C.clearLine}${promptText}${valueText}`);
// 光标定位
if (displayValue) {
process.stdout.write(C.left(displayValue.length - value.length));
}
};
renderInput();
while (!done) {
const key = await readKey();
if (key.name === 'return') {
// 确认
const finalValue = value || defaultValue;
if (validate) {
const error = validate(finalValue);
if (error) {
process.stdout.write(`\n${C.red} ✗ ${error}${C.reset}\n`);
renderInput();
continue;
}
}
done = true;
} else if ((key.name === 'escape' || key.name === 'q') && allowCancel) {
done = true;
cancelled = true;
} else if (key.name === 'backspace') {
if (value.length > 0) {
value = value.slice(0, -1);
renderInput();
}
} else if (key.name === 'paste' && key.paste) {
// 处理粘贴操作 (Bracketed Paste Mode)
// 清理粘贴内容中的控制字符和换行符
const cleanPaste = key.paste
.replace(/[\r\n]+/g, ' ') // 换行符替换为空格
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // 移除 ANSI 转义序列
.replace(/[\x00-\x1f]/g, ''); // 移除控制字符
value += cleanPaste;
renderInput();
} else if (key.char && key.char.length === 1) {
value += key.char;
renderInput();
} else if (key.sequence && key.sequence.length > 1 && !key.name.startsWith('page') &&
!['up', 'down', 'left', 'right', 'home', 'end'].includes(key.name)) {
// 回退处理: 如果收到未知的多字符序列,可能是粘贴内容
// 尝试提取可打印字符
const extracted = key.sequence
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\r\n]+/g, ' ')
.replace(/[\x00-\x1f]/g, '')
.replace(/[^\x20-\x7e]/g, '');
if (extracted.length > 0) {
value += extracted;
renderInput();
}
}
}
} finally {
// 禁用 Bracketed Paste Mode
process.stdout.write('\x1b[?2004l');
process.stdout.write(C.show);
setRawMode(false);
}
if (cancelled) return null;
// 显示最终值 (非隐藏模式)
if (!hidden) {
process.stdout.write(`\n`);
}
return value || defaultValue;
}
/**
* 确认对话框
* @param {object} options 配置选项
* @returns {Promise<boolean>} 用户选择
*/
async function confirm(options = {}) {
const {
prompt = '确认吗?',
defaultYes = true,
} = options;
let selected = defaultYes; // true = Yes, false = No
setRawMode(true);
process.stdout.write(C.hide);
const renderConfirm = () => {
const yesStyle = selected ? `${C.bold}${C.green}` : C.dim;
const noStyle = selected ? C.dim : `${C.bold}${C.red}`;
process.stdout.write(
`\r${C.clearLine}${C.bold}${C.cyan}${prompt}${C.reset} ` +
`${yesStyle}[Y] 是${C.reset} / ` +
`${noStyle}[N] 否${C.reset} ` +
`${C.dim}(←→ 切换, 回车 确认)${C.reset}`
);
};
renderConfirm();
try {
while (true) {
const key = await readKey();
if (key.name === 'return') {
process.stdout.write('\n');
return selected;
} else if (key.name === 'left' || key.name === 'y') {
selected = true;
renderConfirm();
} else if (key.name === 'right' || key.name === 'n') {
selected = false;
renderConfirm();
} else if (key.name === 'escape') {
process.stdout.write('\n');
return false;
}
}
} finally {
process.stdout.write(C.show);
setRawMode(false);
}
}
/**
* 多选菜单 (复选框)
* @param {object} options 配置选项
* @returns {Promise<Array>} 选中的项目数组
*/
async function multiselect(options = {}) {
const {
items = [],
title = '请选择 (空格 切换选中)',
defaultSelected = [],
} = options;
if (items.length === 0) return [];
const selectedSet = new Set(defaultSelected);
let cursor = 0;
let done = false;
let cancelled = false;
setRawMode(true);
process.stdout.write(C.hide);
const render = () => {
const lines = [`\r${C.bold}${C.cyan}${title}${C.reset}\n`];
items.forEach((item, idx) => {
const isCursor = idx === cursor;
const isChecked = selectedSet.has(idx);
const cursorStyle = isCursor ? `${C.bold}${C.cyan}▸` : ' ';
const checkChar = isChecked ? `${C.green}✓${C.reset}` : `${C.dim}○${C.reset}`;
const labelStyle = isCursor ? C.bold : '';
lines.push(`\r${cursorStyle} ${checkChar} ${labelStyle}${item.label}${C.reset}`);
});
lines.push(`\r\n${C.dim}↑↓ 移动 空格 选中 回车 确认 ESC 取消${C.reset}`);
process.stdout.write(C.clearDown + lines.join('\n') + '\n');
};
render();
try {
while (!done) {
const key = await readKey();
if (key.name === 'return') {
done = true;
} else if (key.name === 'escape' || key.name === 'q') {
done = true;
cancelled = true;
} else if (key.name === 'up' && cursor > 0) {
cursor--;
render();
} else if (key.name === 'down' && cursor < items.length - 1) {
cursor++;
render();
} else if (key.name === ' ' || key.name === 'tab') {
if (selectedSet.has(cursor)) {
selectedSet.delete(cursor);
} else {
selectedSet.add(cursor);
}
render();
}
}
} finally {
process.stdout.write(C.show);
setRawMode(false);
}
if (cancelled) return [];
return Array.from(selectedSet).map(idx => items[idx]);
}
// ═══════════════════════════════════════════════════════════════════════════
// 进度指示器
// ═══════════════════════════════════════════════════════════════════════════
/**
* 加载动画 (Spinner)
*/
const spinners = {
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
line: ['-', '\\', '|', '/'],
circle: ['◜', '◠', '◝', '◞', '◡', '◟'],
};
function spinner(options = {}) {
const {
text = '加载中...',
style = 'dots',
} = options;
const frames = spinners[style] || spinners.dots;
let frame = 0;
let interval = null;
let stopped = false;
return {
start() {
process.stdout.write(C.hide);
interval = setInterval(() => {
if (stopped) return;
const symbol = `${C.cyan}${frames[frame]}${C.reset}`;
process.stdout.write(`\r${C.clearLine}${symbol} ${text}`);
frame = (frame + 1) % frames.length;
}, 80);
},
update(newText) {
if (interval && !stopped) {
const symbol = `${C.cyan}${frames[frame]}${C.reset}`;
process.stdout.write(`\r${C.clearLine}${symbol} ${newText}`);
}
},
stop(finalText = '') {
stopped = true;
if (interval) {
clearInterval(interval);
interval = null;
}
process.stdout.write(`\r${C.clearLine}${finalText}\n`);
process.stdout.write(C.show);
},
succeed(text) {
this.stop(`${C.green}✓${C.reset} ${text}`);
},
fail(text) {
this.stop(`${C.red}✗${C.reset} ${text}`);
},
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// 颜色常量
C,
// 核心交互
select,
input,
confirm,
multiselect,
// 工具
spinner,
readKey,
setRawMode,
resetRenderCount,
// 底层渲染
renderMenu,
};