🎨 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

+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,
};