mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 04:45:13 +08:00
🎨 Sync 2026-04-10 15:34:26
This commit is contained in:
1 parent
9b1a57d792
commit
42174fc0b0
66 files changed
+6841
-2210
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user