mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 23:05:13 +08:00
This commit is contained in:
1 parent
f99c0304cd
commit
410d762592
154 files changed
+9225
-8782
No files matched your search
@@ -247,13 +247,24 @@ install_openclaw_cli_wrapper() {
|
||||
# npm 默认生成的 openclaw 可能是指向 openclaw.mjs 的 symlink。
|
||||
# 写 wrapper 前必须先 unlink,否则重定向会顺着 symlink 覆盖真实 CLI 入口。
|
||||
rm -f "$OC_GLOBAL/bin/openclaw" 2>/dev/null || true
|
||||
# 用 : "${VAR:=default}" 而非 export VAR=... 赋默认值。
|
||||
#
|
||||
# 历史实现无条件 export,把 OpenClaw 官方文档承诺的 per-instance 环境变量
|
||||
# (OPENCLAW_HOME / OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH) 全部覆盖,
|
||||
# 连 `openclaw --profile <name>` 也随之失效 —— profile 会解析回同一份
|
||||
# 生产配置。后果是无法做隔离的写入测试,也无法按官方文档跑多实例网关。
|
||||
#
|
||||
# 现改为"仅当调用方未设置时才填默认值",默认行为不变,但允许覆盖。
|
||||
cat > "$OC_GLOBAL/bin/openclaw" << EOF
|
||||
#!/bin/sh
|
||||
export HOME="${OC_DATA}"
|
||||
export OPENCLAW_HOME="${OC_DATA}"
|
||||
export OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw"
|
||||
export OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json"
|
||||
export NODE_ICU_DATA="${NODE_BASE}/share/icu"
|
||||
# luci-app-openclaw 生成的 OpenClaw CLI 包装器。
|
||||
# 只在调用方未提供时设置默认值,保留官方的 per-instance / --profile 隔离能力。
|
||||
: "\${HOME:=${OC_DATA}}"
|
||||
: "\${OPENCLAW_HOME:=${OC_DATA}}"
|
||||
: "\${OPENCLAW_STATE_DIR:=\${OPENCLAW_HOME}/.openclaw}"
|
||||
: "\${OPENCLAW_CONFIG_PATH:=\${OPENCLAW_STATE_DIR}/openclaw.json}"
|
||||
: "\${NODE_ICU_DATA:=${NODE_BASE}/share/icu}"
|
||||
export HOME OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH NODE_ICU_DATA
|
||||
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:\$PATH"
|
||||
exec "${NODE_BIN}" "${oc_entry}" "\$@"
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"_comment": [
|
||||
"精选模型预设 —— shell 与 JS 两侧共读的唯一数据源。",
|
||||
"",
|
||||
"架构分三层 (见 docs/notes/):",
|
||||
" 1. 本文件的精选列表: 离线可用、首屏快,每个 provider 给 4 档",
|
||||
" (推荐 / 最强 / 均衡 / 快速低价)。",
|
||||
" 2. 动态发现: 菜单里的「从 OpenClaw 获取模型列表」调用",
|
||||
" openclaw models list --provider <id> --plain,带超时并在失败时",
|
||||
" 静默回落到本文件。",
|
||||
" 3. 手动输入: 永久保留的兼容出口。",
|
||||
"",
|
||||
"维护约定:",
|
||||
" - model 字段必须是 provider 侧的裸模型 ID (不含 'provider/' 前缀)。",
|
||||
" - 只收录已用 openclaw models list --provider <id> 核实存在的 ID,",
|
||||
" 不要凭文档或印象填写。",
|
||||
" - verified 记录核实日期与 OpenClaw 版本,便于判断是否过期。",
|
||||
" - catalogSource=builtin 表示上游内置 catalog 可直接发现该 provider;",
|
||||
" plugin 表示需先安装官方 provider 插件,模型列表以插件为准。"
|
||||
],
|
||||
"verified": {
|
||||
"date": "2026-08-26",
|
||||
"openclawVersion": "2026.7.1-2",
|
||||
"method": "openclaw models list --provider <id> --plain"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"label": "OpenAI",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "gpt-5.6-sol", "tier": "recommended", "desc": "GPT-5.6 Sol 旗舰 (推荐)" },
|
||||
{ "model": "gpt-5.6", "tier": "strongest", "desc": "GPT-5.6 通用最强" },
|
||||
{ "model": "gpt-5.5", "tier": "balanced", "desc": "GPT-5.5 均衡" },
|
||||
{ "model": "gpt-5.4-mini", "tier": "fast", "desc": "GPT-5.4 mini 快速低价" }
|
||||
]
|
||||
},
|
||||
"anthropic": {
|
||||
"label": "Anthropic",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "claude-sonnet-5", "tier": "recommended", "desc": "Claude Sonnet 5 (推荐, 1M 上下文)" },
|
||||
{ "model": "claude-opus-4-8", "tier": "strongest", "desc": "Claude Opus 4.8 顶级推理" },
|
||||
{ "model": "claude-sonnet-4-6", "tier": "balanced", "desc": "Claude Sonnet 4.6 均衡" },
|
||||
{ "model": "claude-haiku-4-5", "tier": "fast", "desc": "Claude Haiku 4.5 轻量快速" }
|
||||
]
|
||||
},
|
||||
"google": {
|
||||
"label": "Google Gemini",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "gemini-3.1-pro-preview", "tier": "recommended", "desc": "Gemini 3.1 Pro 预览 (推荐)" },
|
||||
{ "model": "gemini-2.5-pro", "tier": "strongest", "desc": "Gemini 2.5 Pro 稳定旗舰" },
|
||||
{ "model": "gemini-3.5-flash", "tier": "balanced", "desc": "Gemini 3.5 Flash 均衡" },
|
||||
{ "model": "gemini-3.1-flash-lite", "tier": "fast", "desc": "Gemini 3.1 Flash-Lite 极速低价" }
|
||||
]
|
||||
},
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "auto", "tier": "recommended", "desc": "自动路由 (推荐)" },
|
||||
{ "model": "moonshotai/kimi-k2.6", "tier": "balanced", "desc": "Kimi K2.6" }
|
||||
]
|
||||
},
|
||||
"github-copilot": {
|
||||
"label": "GitHub Copilot",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "gpt-5.5", "tier": "recommended", "desc": "GPT-5.5 (推荐, 400k 上下文)" },
|
||||
{ "model": "claude-opus-4.8", "tier": "strongest", "desc": "Claude Opus 4.8" },
|
||||
{ "model": "claude-sonnet-4.6", "tier": "balanced", "desc": "Claude Sonnet 4.6" },
|
||||
{ "model": "gpt-5.4-mini", "tier": "fast", "desc": "GPT-5.4 mini" }
|
||||
]
|
||||
},
|
||||
"xai": {
|
||||
"label": "xAI Grok",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "grok-4.3", "tier": "recommended", "desc": "Grok 4.3 (推荐)" },
|
||||
{ "model": "grok-4.20-beta-latest-reasoning", "tier": "strongest", "desc": "Grok 4.20 推理版" },
|
||||
{ "model": "grok-4.20-beta-latest-non-reasoning", "tier": "fast", "desc": "Grok 4.20 非推理版" }
|
||||
]
|
||||
},
|
||||
"mistral": {
|
||||
"label": "Mistral",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "mistral-large-latest", "tier": "recommended", "desc": "Mistral Large (推荐)" },
|
||||
{ "model": "mistral-medium-3-5", "tier": "balanced", "desc": "Mistral Medium 3.5" },
|
||||
{ "model": "codestral-latest", "tier": "strongest", "desc": "Codestral 代码专用" },
|
||||
{ "model": "mistral-small-latest", "tier": "fast", "desc": "Mistral Small 快速" }
|
||||
]
|
||||
},
|
||||
"deepseek": {
|
||||
"label": "DeepSeek",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "deepseek-chat", "tier": "recommended", "desc": "DeepSeek Chat (推荐)" },
|
||||
{ "model": "deepseek-reasoner", "tier": "strongest", "desc": "DeepSeek Reasoner 推理" }
|
||||
]
|
||||
},
|
||||
"moonshot": {
|
||||
"label": "Moonshot Kimi",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "kimi-k2.6", "tier": "recommended", "desc": "Kimi K2.6 (推荐)" },
|
||||
{ "model": "kimi-k2.7-code", "tier": "strongest", "desc": "Kimi K2.7 Code 代码专用" }
|
||||
]
|
||||
},
|
||||
"minimax": {
|
||||
"label": "MiniMax",
|
||||
"catalogSource": "builtin",
|
||||
"models": [
|
||||
{ "model": "MiniMax-M3", "tier": "recommended", "desc": "MiniMax M3 (推荐)" },
|
||||
{ "model": "MiniMax-M2.7", "tier": "balanced", "desc": "MiniMax M2.7" },
|
||||
{ "model": "MiniMax-M2.7-highspeed", "tier": "fast", "desc": "MiniMax M2.7 高速版" }
|
||||
]
|
||||
},
|
||||
"zai": {
|
||||
"label": "智谱 GLM / Z.AI",
|
||||
"catalogSource": "plugin",
|
||||
"models": [
|
||||
{ "model": "glm-5.2", "tier": "recommended", "desc": "GLM-5.2 (推荐)" },
|
||||
{ "model": "glm-5.1", "tier": "strongest", "desc": "GLM-5.1" },
|
||||
{ "model": "glm-4.7", "tier": "balanced", "desc": "GLM-4.7 均衡" },
|
||||
{ "model": "glm-4.7-flash", "tier": "fast", "desc": "GLM-4.7 Flash 快速" }
|
||||
]
|
||||
},
|
||||
"qwen": {
|
||||
"label": "阿里云通义千问 Qwen",
|
||||
"catalogSource": "plugin",
|
||||
"models": [
|
||||
{ "model": "qwen3.5-plus", "tier": "recommended", "desc": "Qwen3.5 Plus (推荐)" },
|
||||
{ "model": "qwen3.6-plus", "tier": "strongest", "desc": "Qwen3.6 Plus" },
|
||||
{ "model": "qwen3-coder-next", "tier": "balanced", "desc": "Qwen3 Coder Next 代码专用" },
|
||||
{ "model": "qwen3-coder-plus", "tier": "fast", "desc": "Qwen3 Coder Plus" }
|
||||
]
|
||||
},
|
||||
"qianfan": {
|
||||
"label": "百度千帆",
|
||||
"catalogSource": "plugin",
|
||||
"models": [
|
||||
{ "model": "ernie-5.0-thinking-preview", "tier": "recommended", "desc": "ERNIE 5.0 Thinking 预览 (推荐)" },
|
||||
{ "model": "deepseek-v3.2", "tier": "balanced", "desc": "DeepSeek V3.2 (千帆托管)" }
|
||||
]
|
||||
},
|
||||
"tencent-tokenhub": {
|
||||
"label": "腾讯混元 TokenHub",
|
||||
"catalogSource": "plugin",
|
||||
"models": [
|
||||
{ "model": "hy3", "tier": "recommended", "desc": "混元 HY3 (推荐)" },
|
||||
{ "model": "hy3-preview", "tier": "strongest", "desc": "混元 HY3 预览版" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,18 @@ const CONFIG_FILE = process.env.OPENCLAW_CONFIG_PATH || `${OC_STATE_DIR}/opencla
|
||||
const NODE_BIN = `${NODE_BASE}/bin/node`;
|
||||
const PERMISSIONS_HELPER = '/usr/libexec/openclaw-permissions.sh';
|
||||
|
||||
// 写前备份的后缀。特意不用 .bak: OpenClaw 自身维护 openclaw.json.bak
|
||||
// 及 .bak.1~.bak.4 轮转,占用同名文件会破坏上游备份链。
|
||||
const CONFIG_BACKUP_SUFFIX = '.luci-pre-write';
|
||||
|
||||
// 精选模型预设 (shell 与 JS 共读的唯一数据源)
|
||||
const MODEL_PRESETS_FILE = process.env.OC_MODEL_PRESETS
|
||||
|| '/usr/share/openclaw/model-presets.json';
|
||||
|
||||
// 动态发现模型列表的超时。OpenWrt 上要避免菜单长时间卡住,
|
||||
// 超时或失败时回落到本地精选预设。
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 6000;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 辅助函数 (与 oc-config.sh 逻辑对应)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -84,26 +96,135 @@ function fixStatePermissions() {
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`${C.red}读取配置失败:${C.reset} ${e.message}`);
|
||||
/**
|
||||
* 配置文件损坏时抛出的专用错误。
|
||||
* 用于把“文件存在但内容不是合法 JSON 对象”与“文件不存在”区分开。
|
||||
*/
|
||||
class ConfigParseError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'ConfigParseError';
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 JSON 配置文件
|
||||
* 配置损坏时给用户的恢复指引。
|
||||
* 不直接自动改文件,避免在用户不知情的情况下丢配置。
|
||||
*/
|
||||
function configRecoveryHint() {
|
||||
const lines = [
|
||||
`${C.yellow}配置文件可能已损坏,为避免覆盖后丢失全部配置,本次操作已中止。${C.reset}`,
|
||||
`${C.yellow}文件:${C.reset} ${CONFIG_FILE}`,
|
||||
'',
|
||||
`${C.cyan}可尝试的恢复方式:${C.reset}`,
|
||||
` 1. 让 OpenClaw 自行修复: openclaw doctor --fix`,
|
||||
` 2. 查看具体语法错误: openclaw config validate`,
|
||||
];
|
||||
const lastGood = `${CONFIG_FILE}.last-good`;
|
||||
if (fs.existsSync(lastGood)) {
|
||||
lines.push(` 3. 从上次正常配置恢复: cp ${lastGood} ${CONFIG_FILE}`);
|
||||
}
|
||||
const preWrite = `${CONFIG_FILE}${CONFIG_BACKUP_SUFFIX}`;
|
||||
if (fs.existsSync(preWrite)) {
|
||||
lines.push(` 4. 从上次修改前的副本恢复: cp ${preWrite} ${CONFIG_FILE}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 JSON 配置文件。
|
||||
*
|
||||
* 语义 (与旧实现的关键差异):
|
||||
* - 文件不存在 / 内容为空 → 返回 {} (首次安装属正常情况)
|
||||
* - 文件存在但解析失败 → 抛出 ConfigParseError,绝不返回 {}
|
||||
*
|
||||
* 旧实现在解析失败时返回 {},调用方随后 writeConfig() 会把这个空对象
|
||||
* 写回磁盘,导致 models.providers / apiKey / channels 等全部配置被静默清空。
|
||||
* 因此这里必须 fail closed,让写入路径中止。
|
||||
*/
|
||||
function readConfig() {
|
||||
if (!fs.existsSync(CONFIG_FILE)) return {};
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
} catch (e) {
|
||||
throw new ConfigParseError(`无法读取配置文件: ${e.message}`);
|
||||
}
|
||||
|
||||
if (raw.trim() === '') return {};
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new ConfigParseError(`配置文件不是合法 JSON: ${e.message}`);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new ConfigParseError('配置根节点必须是 JSON 对象');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读场景下的安全读取: 配置损坏时返回 {} 而不抛错。
|
||||
* 仅供“显示当前值”这类不会回写磁盘的路径使用 (如菜单标题里的当前配置)。
|
||||
* 任何会写回磁盘的逻辑都必须用 readConfig()。
|
||||
*/
|
||||
function readConfigForDisplay() {
|
||||
try {
|
||||
return readConfig();
|
||||
} catch (e) {
|
||||
if (e instanceof ConfigParseError) return {};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 JSON 配置文件 (原子写 + 写前备份 + 写后校验)。
|
||||
*
|
||||
* 旧实现直接 fs.writeFileSync 覆盖目标文件,写入中断会留下截断的 JSON,
|
||||
* 且没有任何备份可回退。这里改为:
|
||||
* 1. 先把现有配置复制为 .luci-pre-write 备份
|
||||
* 2. 写入同目录临时文件
|
||||
* 3. 回读校验临时文件可被 JSON.parse
|
||||
* 4. rename 原子替换目标文件
|
||||
*
|
||||
* 备份后缀特意不用 .bak: OpenClaw 自身维护 openclaw.json.bak 及 .bak.1~.bak.4
|
||||
* 轮转,占用同名文件会破坏上游的备份链。
|
||||
*/
|
||||
function writeConfig(config) {
|
||||
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new Error('拒绝写入非对象配置');
|
||||
}
|
||||
|
||||
const dir = path.dirname(CONFIG_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true }); try { execSync(`chown openclaw:openclaw "${dir}"`, { stdio: "ignore" }); } catch {}
|
||||
}
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
|
||||
// 保留原文件权限位,避免 rename 后权限被临时文件的 0600 覆盖
|
||||
let mode = 0o644;
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try { mode = fs.statSync(CONFIG_FILE).mode & 0o777; } catch {}
|
||||
// 写前备份 (best effort: 备份失败不应阻止用户修改配置)
|
||||
try { fs.copyFileSync(CONFIG_FILE, `${CONFIG_FILE}${CONFIG_BACKUP_SUFFIX}`); } catch {}
|
||||
}
|
||||
|
||||
const payload = `${JSON.stringify(config, null, 2)}\n`;
|
||||
const tmpFile = `${CONFIG_FILE}.tmp-${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, payload, { mode: 0o600 });
|
||||
// 回读校验: 确认落盘内容可解析,避免把坏内容 rename 成正式配置
|
||||
JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
|
||||
try { fs.chmodSync(tmpFile, mode); } catch {}
|
||||
fs.renameSync(tmpFile, CONFIG_FILE);
|
||||
} catch (e) {
|
||||
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
|
||||
throw new Error(`写入配置失败,原配置未被修改: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`chown openclaw:openclaw "${CONFIG_FILE}"`, { stdio: 'ignore' });
|
||||
fixStatePermissions();
|
||||
@@ -112,9 +233,12 @@ function writeConfig(config) {
|
||||
|
||||
/**
|
||||
* 获取 JSON 配置值 (对应 json_get)
|
||||
*
|
||||
* 只读路径: 配置损坏时返回 null 而不抛错,保证菜单仍能打开并显示恢复提示。
|
||||
* 需要回写磁盘的逻辑必须直接用 readConfig(),以便在损坏时中止写入。
|
||||
*/
|
||||
function jsonGet(keyPath) {
|
||||
const config = readConfig();
|
||||
const config = readConfigForDisplay();
|
||||
const keys = keyPath.split('.');
|
||||
let value = config;
|
||||
for (const key of keys) {
|
||||
@@ -148,7 +272,8 @@ function jsonSet(keyPath, value) {
|
||||
function getCurrentModel() {
|
||||
const primary = jsonGet('agents.defaults.model.primary');
|
||||
if (primary) return primary;
|
||||
const config = readConfig();
|
||||
// 只读路径: 与 jsonGet 保持一致,配置损坏时不抛错
|
||||
const config = readConfigForDisplay();
|
||||
if (config.models?.defaultModel) return config.models.defaultModel;
|
||||
return null;
|
||||
}
|
||||
@@ -192,6 +317,207 @@ async function ocCmd(...args) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默执行 OpenClaw CLI 并返回输出。
|
||||
*
|
||||
* ocCmd() 会把子进程输出直接透传到终端,适合交互式命令;
|
||||
* 但解析 `pairing list --json` 这类结果时需要拿到纯输出且不污染界面,
|
||||
* 因此单独提供同步捕获版本。
|
||||
*
|
||||
* 返回 { ok, stdout }: 命令不存在或非零退出时 ok=false,不抛异常。
|
||||
*/
|
||||
function ocCmdCapture(...args) {
|
||||
const ocEntry = findOpenClawEntry();
|
||||
if (!ocEntry) return { ok: false, stdout: '' };
|
||||
try {
|
||||
const stdout = execFileSync(NODE_BIN, [ocEntry, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 20000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: OC_DATA,
|
||||
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
|
||||
OPENCLAW_STATE_DIR: OC_STATE_DIR,
|
||||
HOME: OC_DATA,
|
||||
},
|
||||
});
|
||||
return { ok: true, stdout: stdout || '' };
|
||||
} catch (e) {
|
||||
// 保留 stdout: 部分命令失败时仍会输出有用信息
|
||||
return { ok: false, stdout: (e && (e.stdout || '')) ? String(e.stdout) : '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 `openclaw pairing list <channel> --json` 的输出中提取配对码。
|
||||
* 优先按 JSON 解析;输出夹带非 JSON 前后缀时回退到正则扫描。
|
||||
*/
|
||||
function parsePairingCodes(raw) {
|
||||
if (!raw || !raw.trim()) return [];
|
||||
const codes = [];
|
||||
const pushFrom = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) { node.forEach(pushFrom); return; }
|
||||
if (typeof node.code === 'string' && node.code.trim()) codes.push(node.code.trim());
|
||||
Object.keys(node).forEach((k) => pushFrom(node[k]));
|
||||
};
|
||||
try {
|
||||
pushFrom(JSON.parse(raw));
|
||||
} catch {
|
||||
const re = /"code"\s*:\s*"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(raw)) !== null) codes.push(m[1]);
|
||||
}
|
||||
return [...new Set(codes)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取精选模型预设 (shell 与 JS 共读的唯一数据源)。
|
||||
* 文件缺失或损坏时返回空结构,调用方回落到"手动输入"。
|
||||
*/
|
||||
function loadModelPresets() {
|
||||
try {
|
||||
const raw = fs.readFileSync(MODEL_PRESETS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.providers) return parsed;
|
||||
} catch {}
|
||||
return { providers: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态发现某个 provider 当前实际可用的模型。
|
||||
*
|
||||
* 调 `openclaw models list --provider <id> --plain`,输出形如
|
||||
* "openai/gpt-5.5" 每行一个,这里剥掉 provider 前缀只留裸模型 ID。
|
||||
*
|
||||
* 注意上游行为 (已实测): `models list --all` 不是各 provider 的超集,
|
||||
* 必须按 provider 查询才能拿到完整列表;未安装对应插件的 provider
|
||||
* 会返回 "No models found."。因此失败/空结果都属正常,调用方回落预设。
|
||||
*/
|
||||
function discoverProviderModels(providerId) {
|
||||
const ocEntry = findOpenClawEntry();
|
||||
if (!ocEntry) return [];
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
NODE_BIN,
|
||||
[ocEntry, 'models', 'list', '--provider', providerId, '--plain'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: OC_DATA,
|
||||
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
|
||||
OPENCLAW_STATE_DIR: OC_STATE_DIR,
|
||||
HOME: OC_DATA,
|
||||
},
|
||||
}
|
||||
);
|
||||
const prefix = `${providerId}/`;
|
||||
return [...new Set(
|
||||
String(stdout || '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith(prefix))
|
||||
.map((l) => l.slice(prefix.length))
|
||||
.filter(Boolean)
|
||||
)];
|
||||
} catch {
|
||||
// 超时 / 命令不可用 / provider 无模型: 均回落到精选预设
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模型选择菜单项: 精选预设 + 动态发现 + 手动输入。
|
||||
*
|
||||
* 三层设计的取舍 (架构决定):
|
||||
* - 完全硬编码: 上游更新后必然过期。本项目此前正因如此积累了
|
||||
* openai/gpt-5.2、claude-sonnet-4、grok-4 等一批上游已不存在的 ID。
|
||||
* - 完全动态: 依赖 CLI 可用且已配置 Key,未装插件时返回 0 条,
|
||||
* 且逐 provider 调用在路由器上较慢,不能作为唯一来源。
|
||||
* - 精选 + 动态 + 手动: 首屏快、能自动跟进、离线仍可用。
|
||||
*/
|
||||
function buildModelMenuItems(providerId, opts = {}) {
|
||||
const presets = loadModelPresets();
|
||||
const entry = presets.providers[providerId] || {};
|
||||
const presetModels = Array.isArray(entry.models) ? entry.models : [];
|
||||
const keys = 'abcdefghijkmnpqrstuvwxyz'.split('');
|
||||
const items = [];
|
||||
let ki = 0;
|
||||
|
||||
if (presetModels.length) {
|
||||
items.push({ label: `${C.bold}── 精选模型 ──${C.reset}`, disabled: true });
|
||||
for (const m of presetModels) {
|
||||
items.push({
|
||||
key: keys[ki++] || undefined,
|
||||
label: m.model,
|
||||
desc: m.desc || '',
|
||||
value: m.model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 动态发现: 只展示不在精选列表里的,避免重复
|
||||
if (opts.discovered && opts.discovered.length) {
|
||||
const known = new Set(presetModels.map((m) => m.model));
|
||||
const extra = opts.discovered.filter((m) => !known.has(m));
|
||||
if (extra.length) {
|
||||
items.push({ label: `${C.bold}── OpenClaw 当前可用 ──${C.reset}`, disabled: true });
|
||||
for (const m of extra) {
|
||||
items.push({ key: keys[ki++] || undefined, label: m, desc: '', value: m });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ label: '', disabled: true });
|
||||
if (!opts.discovered) {
|
||||
items.push({ key: 'r', label: '从 OpenClaw 获取完整模型列表', desc: '动态发现当前可用模型', value: '__discover__' });
|
||||
}
|
||||
// 手动输入是永久保留的兼容出口: 上游新增模型时无需等插件更新
|
||||
items.push({ key: 'z', label: '手动输入模型 ID', desc: '', value: '__custom__' });
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的模型选择流程: 处理动态发现与手动输入两个特殊分支。
|
||||
* 返回裸模型 ID (不含 provider 前缀),取消时返回 null。
|
||||
*/
|
||||
async function selectProviderModel(providerId, title, fallbackDefault) {
|
||||
let discovered = null;
|
||||
for (let round = 0; round < 2; round++) {
|
||||
const choice = await select({
|
||||
title,
|
||||
showSearch: true,
|
||||
items: buildModelMenuItems(providerId, discovered ? { discovered } : {}),
|
||||
});
|
||||
if (!choice) return null;
|
||||
|
||||
if (choice.value === '__discover__') {
|
||||
console.log(`\n${C.cyan}正在从 OpenClaw 获取模型列表...${C.reset}`);
|
||||
discovered = discoverProviderModels(providerId);
|
||||
if (!discovered.length) {
|
||||
console.log(`${C.yellow}未获取到模型列表 (该 Provider 可能需要先安装插件或配置 API Key),`
|
||||
+ `已显示内置精选列表。${C.reset}\n`);
|
||||
discovered = [];
|
||||
} else {
|
||||
console.log(`${C.green}获取到 ${discovered.length} 个模型${C.reset}\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (choice.value === '__custom__') {
|
||||
const manual = await input({ prompt: '请输入模型 ID', defaultValue: fallbackDefault || '' });
|
||||
const trimmed = manual ? String(manual).trim() : '';
|
||||
return trimmed || null;
|
||||
}
|
||||
return choice.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册模型并设为默认 (对应 register_and_set_model)
|
||||
*/
|
||||
@@ -485,12 +811,12 @@ async function showChannelsMenu() {
|
||||
{ label: `${C.dim}提示: 微信配置请使用 LuCI 界面「微信配置」菜单${C.reset}`, disabled: true },
|
||||
{ label: '', disabled: true },
|
||||
{ key: '1', label: 'QQ 机器人', desc: '腾讯QQ', value: 'qq' },
|
||||
{ key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset}`, value: 'telegram' },
|
||||
{ key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset} — 配置 Bot Token`, value: 'telegram' },
|
||||
{ key: '3', label: 'Discord', desc: '', value: 'discord' },
|
||||
{ key: '4', label: '飞书 (Feishu)', desc: '', value: 'feishu' },
|
||||
{ key: '5', label: 'Slack', desc: '', value: 'slack' },
|
||||
{ key: '6', label: 'WhatsApp', desc: '需通过 Web 控制台扫码', value: 'whatsapp' },
|
||||
{ key: '7', label: 'Telegram 配对助手', desc: '', value: 'telegram-pairing' },
|
||||
{ key: '7', label: 'Telegram 配对助手', desc: '审批用户配对请求 (需先配置 Token)', value: 'telegram-pairing' },
|
||||
{ key: '8', label: '官方完整渠道配置向导', desc: '', value: 'wizard' },
|
||||
{ label: '', disabled: true },
|
||||
{ key: '0', label: '返回', desc: '', value: 'back' },
|
||||
@@ -508,7 +834,9 @@ async function showAdvancedMenu() {
|
||||
const gwPort = jsonGet('gateway.port') || '18789';
|
||||
const gwBind = jsonGet('gateway.bind') || 'lan';
|
||||
const gwMode = jsonGet('gateway.mode') || 'local';
|
||||
const logLevel = jsonGet('gateway.logLevel') || '未设置';
|
||||
// 正确键是顶层 logging.level;gateway.logLevel 不在 OpenClaw schema 中
|
||||
// (gateway.additionalProperties=false),仅为显示旧配置残留值做兼容读取。
|
||||
const logLevel = jsonGet('logging.level') || jsonGet('gateway.logLevel') || '未设置';
|
||||
const acpDispatch = jsonGet('acp.dispatch.enabled') || 'false';
|
||||
|
||||
const result = await select({
|
||||
@@ -590,26 +918,8 @@ async function configureOpenAI() {
|
||||
const apiKey = await input({ prompt: '请输入 OpenAI API Key (sk-...)', placeholder: 'sk-...' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'OpenAI 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'gpt-5.2', desc: '最强编程与代理旗舰 (推荐)', value: 'gpt-5.2' },
|
||||
{ key: 'b', label: 'gpt-5-mini', desc: '高性价比推理', value: 'gpt-5-mini' },
|
||||
{ key: 'c', label: 'gpt-5-nano', desc: '极速低成本', value: 'gpt-5-nano' },
|
||||
{ key: 'd', label: 'gpt-4.1', desc: '最强非推理模型', value: 'gpt-4.1' },
|
||||
{ key: 'e', label: 'o3', desc: '推理模型', value: 'o3' },
|
||||
{ key: 'f', label: 'o4-mini', desc: '推理轻量', value: 'o4-mini' },
|
||||
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-5.2' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await selectProviderModel('openai', 'OpenAI 模型选择', 'gpt-5.6-sol');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('openai', apiKey);
|
||||
registerAndSetModel(`openai/${modelName}`);
|
||||
@@ -625,25 +935,8 @@ async function configureAnthropic() {
|
||||
const apiKey = await input({ prompt: '请输入 Anthropic API Key (sk-ant-...)', placeholder: 'sk-ant-...' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'Anthropic 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' },
|
||||
{ key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4 顶级推理', value: 'claude-opus-4-20250514' },
|
||||
{ key: 'c', label: 'claude-haiku-4-5', desc: 'Claude Haiku 4.5 轻量快速', value: 'claude-haiku-4-5' },
|
||||
{ key: 'd', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' },
|
||||
{ key: 'e', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' },
|
||||
{ key: 'f', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await selectProviderModel('anthropic', 'Anthropic 模型选择', 'claude-sonnet-5');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('anthropic', apiKey);
|
||||
registerAndSetModel(`anthropic/${modelName}`);
|
||||
@@ -659,25 +952,8 @@ async function configureGoogle() {
|
||||
const apiKey = await input({ prompt: '请输入 Google AI API Key', placeholder: 'AIza...' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'Google Gemini 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'gemini-2.5-pro', desc: '旗舰推理 (推荐)', value: 'gemini-2.5-pro' },
|
||||
{ key: 'b', label: 'gemini-2.5-flash', desc: '快速均衡', value: 'gemini-2.5-flash' },
|
||||
{ key: 'c', label: 'gemini-2.5-flash-lite', desc: '极速低成本', value: 'gemini-2.5-flash-lite' },
|
||||
{ key: 'd', label: 'gemini-3-flash-preview', desc: 'Gemini 3 Flash 预览', value: 'gemini-3-flash-preview' },
|
||||
{ key: 'e', label: 'gemini-3-pro-preview', desc: 'Gemini 3 Pro 预览', value: 'gemini-3-pro-preview' },
|
||||
{ key: 'f', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gemini-2.5-pro' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await selectProviderModel('google', 'Google Gemini 模型选择', 'gemini-2.5-pro');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('google', apiKey);
|
||||
registerAndSetModel(`google/${modelName}`);
|
||||
@@ -694,26 +970,9 @@ async function configureOpenRouter() {
|
||||
const apiKey = await input({ prompt: '请输入 OpenRouter API Key', placeholder: 'sk-or-...' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'OpenRouter 常用模型',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 (推荐)', value: 'anthropic/claude-sonnet-4' },
|
||||
{ key: 'b', label: 'anthropic/claude-opus-4', desc: 'Claude Opus 4', value: 'anthropic/claude-opus-4' },
|
||||
{ key: 'c', label: 'openai/gpt-5.2', desc: 'GPT-5.2', value: 'openai/gpt-5.2' },
|
||||
{ key: 'd', label: 'google/gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' },
|
||||
{ key: 'e', label: 'deepseek/deepseek-r1', desc: 'DeepSeek R1', value: 'deepseek/deepseek-r1' },
|
||||
{ key: 'f', label: 'meta-llama/llama-4-maverick', desc: 'Meta Llama 4', value: 'meta-llama/llama-4-maverick' },
|
||||
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称 (格式: provider/model)', defaultValue: 'anthropic/claude-sonnet-4' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
// OpenRouter 的模型 ID 本身带上游 provider 前缀 (如 moonshotai/kimi-k2.6)
|
||||
const modelName = await selectProviderModel('openrouter', 'OpenRouter 模型选择', 'auto');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('openrouter', apiKey);
|
||||
registerCustomProvider('openrouter', 'https://openrouter.ai/api/v1', apiKey, modelName, modelName);
|
||||
@@ -734,31 +993,9 @@ async function configureCopilot() {
|
||||
await ocCmd('models', 'auth', 'login-github-copilot', '--yes');
|
||||
console.log(`\n${C.green}✅ GitHub Copilot OAuth 认证成功${C.reset}\n`);
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'GitHub Copilot 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'gpt-4.1', desc: 'GPT-4.1 (推荐)', value: 'gpt-4.1' },
|
||||
{ key: 'b', label: 'gpt-4o', desc: 'GPT-4o', value: 'gpt-4o' },
|
||||
{ key: 'c', label: 'gpt-5', desc: 'GPT-5', value: 'gpt-5' },
|
||||
{ key: 'd', label: 'gpt-5-mini', desc: 'GPT-5 mini', value: 'gpt-5-mini' },
|
||||
{ key: 'e', label: 'gpt-5.1', desc: 'GPT-5.1', value: 'gpt-5.1' },
|
||||
{ key: 'f', label: 'gpt-5.2', desc: 'GPT-5.2', value: 'gpt-5.2' },
|
||||
{ key: 'g', label: 'gpt-5.2-codex', desc: 'GPT-5.2 Codex', value: 'gpt-5.2-codex' },
|
||||
{ key: 'h', label: 'claude-sonnet-4', desc: 'Claude Sonnet 4', value: 'claude-sonnet-4' },
|
||||
{ key: 'i', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' },
|
||||
{ key: 'j', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' },
|
||||
{ key: 'k', label: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro' },
|
||||
{ key: 'm', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
|
||||
if (modelChoice) {
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-4.1' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
// 登录成功后动态发现通常可用,因此这里默认就尝试拉取一次
|
||||
const modelName = await selectProviderModel('github-copilot', 'GitHub Copilot 模型选择', 'gpt-5.5');
|
||||
if (modelName) {
|
||||
registerAndSetModel(`github-copilot/${modelName}`);
|
||||
console.log(`\n${C.green}✅ 活跃模型已设置: github-copilot/${modelName}${C.reset}\n`);
|
||||
}
|
||||
@@ -777,26 +1014,8 @@ async function configureXAI() {
|
||||
const apiKey = await input({ prompt: '请输入 xAI API Key', placeholder: '' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'xAI Grok 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'grok-4', desc: 'Grok 4 旗舰 (推荐)', value: 'grok-4' },
|
||||
{ key: 'b', label: 'grok-4-fast', desc: 'Grok 4 Fast', value: 'grok-4-fast' },
|
||||
{ key: 'c', label: 'grok-3', desc: 'Grok 3', value: 'grok-3' },
|
||||
{ key: 'd', label: 'grok-3-fast', desc: 'Grok 3 Fast', value: 'grok-3-fast' },
|
||||
{ key: 'e', label: 'grok-3-mini', desc: 'Grok 3 Mini', value: 'grok-3-mini' },
|
||||
{ key: 'f', label: 'grok-3-mini-fast', desc: 'Grok 3 Mini Fast', value: 'grok-3-mini-fast' },
|
||||
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'grok-4' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await selectProviderModel('xai', 'xAI Grok 模型选择', 'grok-4.3');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('xai', apiKey);
|
||||
registerAndSetModel(`xai/${modelName}`);
|
||||
@@ -916,34 +1135,19 @@ async function configureSiliconFlow() {
|
||||
const apiKey = await input({ prompt: '请输入 SiliconFlow API Key', placeholder: '' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
console.log(`\n${C.cyan}可用模型分类说明:${C.reset}`);
|
||||
console.log(`${C.yellow}* Pro模型: 仅支持充值余额支付${C.reset}`);
|
||||
console.log(`${C.yellow}* 非Pro模型: 支持代金券/免费额度${C.reset}\n`);
|
||||
// SiliconFlow 不是 OpenClaw 内置/官方插件 provider,走 OpenAI 兼容接入,
|
||||
// 因此 `openclaw models list --provider siliconflow` 拿不到列表,
|
||||
// 也无法用上游 catalog 核实模型 ID。
|
||||
//
|
||||
// 此处不再维护硬编码清单: 原列表里的 Qwen2.5-7B/72B、Yi-1.5-34B-Chat-16K、
|
||||
// glm-4-9b-chat 均已明显过期,而我们无法自动判断哪些仍然有效。
|
||||
// 让用户从官方模型广场复制当前 ID 更可靠,也不会随时间腐坏。
|
||||
console.log(`\n${C.cyan}请从官方模型广场复制模型 ID:${C.reset}`);
|
||||
console.log(` ${C.cyan}https://cloud.siliconflow.cn/models${C.reset}`);
|
||||
console.log(`${C.dim}格式形如 deepseek-ai/DeepSeek-V3.2;Pro/ 前缀的模型仅支持充值余额支付${C.reset}\n`);
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'SiliconFlow 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ label: `${C.cyan}── 非Pro模型 (支持代金券) ──${C.reset}`, disabled: true },
|
||||
{ key: '1', label: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (推荐)', value: 'deepseek-ai/DeepSeek-V3' },
|
||||
{ key: '2', label: 'deepseek-ai/DeepSeek-R1', desc: 'DeepSeek-R1 推理模型', value: 'deepseek-ai/DeepSeek-R1' },
|
||||
{ key: '3', label: 'Qwen/Qwen2.5-72B-Instruct', desc: '通义千问 2.5 72B', value: 'Qwen/Qwen2.5-72B-Instruct' },
|
||||
{ key: '4', label: 'Qwen/Qwen2.5-7B-Instruct', desc: '通义千问 2.5 7B', value: 'Qwen/Qwen2.5-7B-Instruct' },
|
||||
{ key: '5', label: 'THUDM/glm-4-9b-chat', desc: '智谱 GLM-4 9B', value: 'THUDM/glm-4-9b-chat' },
|
||||
{ key: '6', label: '01-ai/Yi-1.5-34B-Chat-16K', desc: '零一万物 Yi-1.5', value: '01-ai/Yi-1.5-34B-Chat-16K' },
|
||||
{ label: `${C.cyan}── Pro模型 (仅支持充值余额) ──${C.reset}`, disabled: true },
|
||||
{ key: '7', label: 'Pro/deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (Pro)', value: 'Pro/deepseek-ai/DeepSeek-V3' },
|
||||
{ key: '8', label: 'Pro/zai-org/GLM-5', desc: '智谱 GLM-5', value: 'Pro/zai-org/GLM-5' },
|
||||
{ key: '0', label: '手动输入其他模型名称', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型详细名称', defaultValue: 'deepseek-ai/DeepSeek-V3' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await input({ prompt: '请输入模型 ID', placeholder: 'deepseek-ai/DeepSeek-V3.2' });
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('siliconflow', apiKey);
|
||||
registerCustomProvider('siliconflow', 'https://api.siliconflow.cn/v1', apiKey, modelName, modelName);
|
||||
@@ -1001,24 +1205,8 @@ async function configureBaidu() {
|
||||
const apiKey = await input({ prompt: '请输入百度千帆 API Key (Access Token)', placeholder: '' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: '百度千帆模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'ernie-4.0-8k', desc: '文心一言 4.0 (推荐)', value: 'ernie-4.0-8k' },
|
||||
{ key: 'b', label: 'ernie-3.5-8k', desc: '文心一言 3.5', value: 'ernie-3.5-8k' },
|
||||
{ key: 'c', label: 'ernie-4.0-turbo-8k', desc: '文心一言 4.0 Turbo', value: 'ernie-4.0-turbo-8k' },
|
||||
{ key: 'd', label: 'ernie-speed-8k', desc: '文心一言 Speed 极速', value: 'ernie-speed-8k' },
|
||||
{ key: 'e', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'ernie-4.0-8k' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await selectProviderModel('qianfan', '百度千帆模型选择', 'ernie-5.0-thinking-preview');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
authSetApikey('qianfan', apiKey);
|
||||
registerCustomProvider('qianfan', 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop', apiKey, modelName, modelName);
|
||||
@@ -1065,26 +1253,8 @@ async function configureZhipu() {
|
||||
const apiKey = await input({ prompt: '请输入智谱 API Key', placeholder: '' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: '智谱 GLM 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'glm-5.1', desc: 'GLM-5.1 (推荐)', value: 'glm-5.1' },
|
||||
{ key: 'b', label: 'glm-5', desc: 'GLM-5', value: 'glm-5' },
|
||||
{ key: 'c', label: 'glm-4.7', desc: 'GLM-4.7', value: 'glm-4.7' },
|
||||
{ key: 'd', label: 'glm-4.7-flash', desc: 'GLM-4.7 Flash', value: 'glm-4.7-flash' },
|
||||
{ key: 'e', label: 'glm-4.5', desc: 'GLM-4.5', value: 'glm-4.5' },
|
||||
{ key: 'f', label: 'glm-4.5-flash', desc: 'GLM-4.5 Flash (免费)', value: 'glm-4.5-flash' },
|
||||
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'glm-5.1' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
const modelName = await selectProviderModel('zai', '智谱 GLM 模型选择', 'glm-5.2');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
// 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持)
|
||||
registerCustomProvider('zai', baseUrl, apiKey, modelName, modelName, 128000, 4096);
|
||||
@@ -1219,22 +1389,10 @@ async function configureCustomAnthropic() {
|
||||
const apiKey = await input({ prompt: 'API Key', placeholder: 'sk-ant-...' });
|
||||
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const modelChoice = await select({
|
||||
title: 'Anthropic 模型选择',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' },
|
||||
{ key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4', value: 'claude-opus-4-20250514' },
|
||||
{ key: 'c', label: '手动输入模型名', desc: '', value: 'custom' },
|
||||
],
|
||||
});
|
||||
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
let modelName = modelChoice.value;
|
||||
if (modelChoice.value === 'custom') {
|
||||
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' });
|
||||
if (!modelName) return false;
|
||||
}
|
||||
// 复用 anthropic 的精选预设作为候选 (第三方兼容服务通常沿用同名模型 ID),
|
||||
// 但保留手动输入出口: 自建/代理服务的模型名可能完全不同。
|
||||
const modelName = await selectProviderModel('anthropic', '模型选择 (可手动输入)', 'claude-sonnet-5');
|
||||
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
|
||||
|
||||
const config = readConfig();
|
||||
if (!config.models) config.models = {};
|
||||
@@ -1375,7 +1533,7 @@ async function handleSetActiveModel() {
|
||||
if (choice.value === 'manual') {
|
||||
const manualModel = await input({
|
||||
prompt: '请输入模型 ID',
|
||||
placeholder: 'openai/gpt-4o',
|
||||
placeholder: 'openai/gpt-5.6-sol',
|
||||
defaultValue: currentModel || '',
|
||||
});
|
||||
if (manualModel) {
|
||||
@@ -1531,10 +1689,100 @@ async function handleChannels() {
|
||||
break;
|
||||
}
|
||||
case 'telegram-pairing': {
|
||||
console.log(`\n${C.cyan}启动 Telegram 配对助手...${C.reset}\n`);
|
||||
// 配对 (pairing) 与 Bot Token 配置是两件不同的事:
|
||||
// - Token 配置 -> 上面的 'telegram' 分支 / channels add --bot-token
|
||||
// - 用户配对 -> openclaw pairing list / pairing approve
|
||||
// 旧实现调用的 `models auth login-telegram-bot` 在 2026.6+ 已不存在
|
||||
// (实测报 Too many arguments for this command),这里改为对齐上游
|
||||
// pairing 命令,与 oc-config.sh 的 telegram_pairing() 行为保持一致。
|
||||
console.log(`\n${C.bold}🤝 Telegram 配对助手${C.reset}\n`);
|
||||
|
||||
const tgToken = jsonGet('channels.telegram.botToken');
|
||||
if (!tgToken) {
|
||||
console.log(`${C.yellow}未检测到 Telegram Bot Token,请先在「Telegram」中配置 Token。${C.reset}\n`);
|
||||
await input({ prompt: '按回车返回', defaultValue: '' });
|
||||
break;
|
||||
}
|
||||
|
||||
// 先确认 Token 与网络可用,避免用户在配对环节盲等
|
||||
console.log(`${C.cyan}诊断 Telegram API 连通性...${C.reset}`);
|
||||
let botName = '';
|
||||
try {
|
||||
await ocCmd('models', 'auth', 'login-telegram-bot');
|
||||
} catch (e) {}
|
||||
const probe = execSync(
|
||||
`curl -s --connect-timeout 5 --max-time 10 "https://api.telegram.org/bot${tgToken}/getMe"`,
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
if (/"ok"\s*:\s*true/.test(probe)) {
|
||||
const m = probe.match(/"username"\s*:\s*"([^"]+)"/);
|
||||
botName = m ? m[1] : '';
|
||||
console.log(`${C.green}✅ Telegram API 连通正常${botName ? ` — @${botName}` : ''}${C.reset}`);
|
||||
} else {
|
||||
console.log(`${C.red}❌ Telegram API 连通检测未通过${C.reset}`);
|
||||
console.log(`${C.yellow} 可能原因: Token 不正确、网络不通 或 Telegram 被屏蔽${C.reset}`);
|
||||
console.log(`${C.cyan} 建议: 重新配置 Token 或检查代理/网络设置${C.reset}\n`);
|
||||
await input({ prompt: '按回车返回', defaultValue: '' });
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
console.log(`${C.yellow}⚠️ 无法完成连通性检测,继续尝试配对${C.reset}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`${C.green}请在 Telegram 中向 Bot 发送 /start,然后回到这里继续${C.reset}`);
|
||||
console.log('');
|
||||
const go = await input({ prompt: '发送 /start 后按回车继续 (输入 q 退出)', defaultValue: '' });
|
||||
if (String(go).toLowerCase() === 'q') break;
|
||||
|
||||
const approveCode = (code) => {
|
||||
const res = ocCmdCapture('pairing', 'approve', 'telegram', code);
|
||||
return res.ok || /approved|success|ok/i.test(res.stdout);
|
||||
};
|
||||
|
||||
let paired = false;
|
||||
for (let attempt = 1; attempt <= 3 && !paired; attempt++) {
|
||||
console.log(`${C.cyan}检测配对请求... (第 ${attempt}/3 轮)${C.reset}`);
|
||||
const listed = ocCmdCapture('pairing', 'list', 'telegram', '--json');
|
||||
const codes = parsePairingCodes(listed.stdout);
|
||||
|
||||
for (const code of codes) {
|
||||
console.log(`${C.cyan}发现配对请求: ${code}${C.reset}`);
|
||||
if (approveCode(code)) {
|
||||
console.log(`\n${C.green}${C.bold}🎉 Telegram 配对成功!${C.reset}`);
|
||||
paired = true;
|
||||
} else {
|
||||
console.log(`${C.yellow}配对码 ${code} 处理失败${C.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!paired && attempt < 3) {
|
||||
console.log(`${C.yellow}未检测到,等待 8 秒后重试...${C.reset}`);
|
||||
await new Promise((r) => setTimeout(r, 8000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!paired) {
|
||||
console.log('');
|
||||
console.log(`${C.yellow}未自动检测到配对请求。${C.reset}`);
|
||||
console.log(`${C.cyan}如果 Bot 已回复配对码,可手动输入 (回车跳过):${C.reset}`);
|
||||
const manual = await input({ prompt: '配对码', defaultValue: '' });
|
||||
if (manual) {
|
||||
if (approveCode(String(manual).trim())) {
|
||||
console.log(`${C.green}${C.bold}🎉 Telegram 配对成功!${C.reset}`);
|
||||
paired = true;
|
||||
} else {
|
||||
console.log(`${C.yellow}配对失败,请确认配对码是否正确或重新发送 /start${C.reset}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paired) {
|
||||
console.log(`\n${C.cyan}正在重启 Gateway 使配对生效...${C.reset}`);
|
||||
await restartGateway();
|
||||
console.log(`${C.green}✅ 现在可以在 Telegram 中与 Bot 对话了!${C.reset}\n`);
|
||||
} else {
|
||||
console.log('');
|
||||
await input({ prompt: '按回车返回', defaultValue: '' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'wizard': {
|
||||
@@ -1673,12 +1921,21 @@ async function handleAdvancedConfig() {
|
||||
title: '绑定地址选项',
|
||||
showSearch: false,
|
||||
items: [
|
||||
// 取值必须与 schema 的 gateway.bind 枚举一致:
|
||||
// auto/lan/loopback/custom/tailnet。旧选项 all 不被上游接受
|
||||
// (实测: gateway.bind: Invalid input),监听所有接口用 custom。
|
||||
{ key: '1', label: 'lan', desc: '仅 LAN 接口 (推荐)', value: 'lan' },
|
||||
{ key: '2', label: 'loopback', desc: '仅本机访问', value: 'loopback' },
|
||||
{ key: '3', label: 'all', desc: '所有接口 (0.0.0.0)', value: 'all' },
|
||||
{ key: '3', label: 'auto', desc: '自动选择', value: 'auto' },
|
||||
{ key: '4', label: 'custom', desc: '自定义地址 (0.0.0.0 = 所有接口)', value: 'custom' },
|
||||
{ key: '5', label: 'tailnet', desc: 'Tailscale 网络', value: 'tailnet' },
|
||||
],
|
||||
});
|
||||
if (bindChoice) {
|
||||
if (bindChoice.value === 'custom') {
|
||||
const host = await input({ prompt: '监听地址', defaultValue: jsonGet('gateway.customBindHost') || '0.0.0.0' });
|
||||
if (host) jsonSet('gateway.customBindHost', host);
|
||||
}
|
||||
jsonSet('gateway.bind', bindChoice.value);
|
||||
try { execSync(`uci set openclaw.main.bind="${bindChoice.value}" && uci commit openclaw`, { stdio: 'ignore' }); } catch {}
|
||||
console.log(`\n${C.green}✅ 绑定地址已设置为 ${bindChoice.value}${C.reset}\n`);
|
||||
@@ -1707,14 +1964,28 @@ async function handleAdvancedConfig() {
|
||||
title: '日志级别选项',
|
||||
showSearch: false,
|
||||
items: [
|
||||
{ key: '1', label: 'debug', desc: '详细调试', value: 'debug' },
|
||||
{ key: '2', label: 'info', desc: '常规信息', value: 'info' },
|
||||
{ key: '3', label: 'warn', desc: '警告及以上', value: 'warn' },
|
||||
{ key: '4', label: 'error', desc: '仅错误', value: 'error' },
|
||||
// 取值与 schema 的 logging.level 枚举一致 (7 档)
|
||||
{ key: '1', label: 'info', desc: '常规信息 (默认)', value: 'info' },
|
||||
{ key: '2', label: 'warn', desc: '警告及以上', value: 'warn' },
|
||||
{ key: '3', label: 'error', desc: '仅错误', value: 'error' },
|
||||
{ key: '4', label: 'debug', desc: '详细调试', value: 'debug' },
|
||||
{ key: '5', label: 'trace', desc: '最详细 (排障用)', value: 'trace' },
|
||||
{ key: '6', label: 'fatal', desc: '仅致命错误', value: 'fatal' },
|
||||
{ key: '7', label: 'silent', desc: '完全静默', value: 'silent' },
|
||||
],
|
||||
});
|
||||
if (levelChoice) {
|
||||
jsonSet('gateway.logLevel', levelChoice.value);
|
||||
// 正确键是顶层 logging.level;gateway.logLevel 不在 schema 中,
|
||||
// 写入会被上游忽略,表现为"显示已设置但从未生效"。
|
||||
jsonSet('logging.level', levelChoice.value);
|
||||
// 清理旧配置里可能残留的错误键,避免界面回显到失效值
|
||||
try {
|
||||
const cfg = readConfig();
|
||||
if (cfg.gateway && Object.prototype.hasOwnProperty.call(cfg.gateway, 'logLevel')) {
|
||||
delete cfg.gateway.logLevel;
|
||||
writeConfig(cfg);
|
||||
}
|
||||
} catch {}
|
||||
console.log(`\n${C.green}✅ 日志级别已设置为 ${levelChoice.value}${C.reset}\n`);
|
||||
await askRestart();
|
||||
}
|
||||
@@ -2076,6 +2347,13 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
if (e instanceof ConfigParseError) {
|
||||
// 配置损坏: 给出可直接执行的恢复步骤,而不是丢一句解析错误了事
|
||||
console.error(`\n${C.red}配置读取失败:${C.reset} ${e.message}\n`);
|
||||
console.error(configRecoveryHint());
|
||||
console.error('');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`${C.red}错误:${C.reset}`, e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -126,18 +126,166 @@ oc_doctor_as_openclaw() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 精选模型预设 (与 JS 侧共读同一份数据源) ──
|
||||
# 见 root/usr/share/openclaw/model-presets.json。
|
||||
# 三层架构: 精选预设 -> 动态发现 (openclaw models list --provider) -> 手动输入。
|
||||
OC_MODEL_PRESETS="${OC_MODEL_PRESETS:-/usr/share/openclaw/model-presets.json}"
|
||||
OC_MODEL_DISCOVERY_TIMEOUT="${OC_MODEL_DISCOVERY_TIMEOUT:-6}"
|
||||
|
||||
# oc_preset_models <provider> — 输出该 provider 的精选模型,每行 "model<TAB>desc"
|
||||
oc_preset_models() {
|
||||
[ -f "$OC_MODEL_PRESETS" ] || return 0
|
||||
[ -x "$NODE_BIN" ] || return 0
|
||||
_OC_PP="$1" _OC_PF="$OC_MODEL_PRESETS" "$NODE_BIN" -e '
|
||||
const fs=require("fs");
|
||||
try{
|
||||
const j=JSON.parse(fs.readFileSync(process.env._OC_PF,"utf8"));
|
||||
const p=(j.providers||{})[process.env._OC_PP];
|
||||
if(!p||!Array.isArray(p.models))process.exit(0);
|
||||
for(const m of p.models){
|
||||
if(m&&m.model)console.log(m.model+"\t"+(m.desc||""));
|
||||
}
|
||||
}catch(e){}
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# oc_discover_models <provider> — 动态发现当前可用模型 (失败/超时输出空)
|
||||
# 上游行为: models list --all 不是各 provider 的超集,必须按 provider 查询;
|
||||
# 未安装对应插件时返回 "No models found.",属正常情况。
|
||||
# 带 timeout: 避免在 OpenWrt 上把菜单卡死。
|
||||
oc_discover_models() {
|
||||
local provider="$1"
|
||||
[ -x "$NODE_BIN" ] || return 0
|
||||
[ -n "$OC_ENTRY" ] || return 0
|
||||
timeout "$OC_MODEL_DISCOVERY_TIMEOUT" "$NODE_BIN" "$OC_ENTRY" models list \
|
||||
--provider "$provider" --plain 2>/dev/null \
|
||||
| sed -n "s|^${provider}/||p" \
|
||||
| grep -v '^[[:space:]]*$'
|
||||
}
|
||||
|
||||
# oc_pick_model <provider> <默认模型> — 交互式选择模型,结果写入全局 OC_PICKED_MODEL
|
||||
# 菜单: 精选预设 (数字) / d 动态发现 / m 手动输入
|
||||
oc_pick_model() {
|
||||
local provider="$1" fallback="$2"
|
||||
local list idx line model desc choice
|
||||
OC_PICKED_MODEL=""
|
||||
|
||||
list="$(oc_preset_models "$provider")"
|
||||
|
||||
echo ""
|
||||
if [ -n "$list" ]; then
|
||||
echo -e " ${CYAN}精选模型:${NC}"
|
||||
idx=1
|
||||
printf '%s\n' "$list" | while IFS="$(printf '\t')" read -r model desc; do
|
||||
[ -n "$model" ] || continue
|
||||
echo -e " ${CYAN}${idx})${NC} ${model}${desc:+ — ${desc}}"
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
else
|
||||
echo -e " ${YELLOW}该 Provider 无内置精选列表${NC}"
|
||||
fi
|
||||
echo -e " ${CYAN}d)${NC} 从 OpenClaw 获取完整模型列表"
|
||||
echo -e " ${CYAN}m)${NC} 手动输入模型 ID"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "1" choice
|
||||
|
||||
case "$choice" in
|
||||
d|D)
|
||||
echo -e " ${CYAN}正在获取模型列表...${NC}"
|
||||
local found
|
||||
found="$(oc_discover_models "$provider")"
|
||||
if [ -z "$found" ]; then
|
||||
echo -e " ${YELLOW}未获取到模型列表 (可能需先安装插件或配置 API Key)${NC}"
|
||||
prompt_with_default "请手动输入模型 ID" "$fallback" OC_PICKED_MODEL
|
||||
else
|
||||
echo ""
|
||||
idx=1
|
||||
printf '%s\n' "$found" | while read -r model; do
|
||||
[ -n "$model" ] || continue
|
||||
echo -e " ${CYAN}${idx})${NC} ${model}"
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
echo ""
|
||||
prompt_with_default "请输入序号或直接输入模型 ID" "1" choice
|
||||
case "$choice" in
|
||||
''|*[!0-9]*) OC_PICKED_MODEL="$choice" ;;
|
||||
*) OC_PICKED_MODEL="$(printf '%s\n' "$found" | sed -n "${choice}p")" ;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
m|M)
|
||||
prompt_with_default "请输入模型 ID" "$fallback" OC_PICKED_MODEL
|
||||
;;
|
||||
''|*[!0-9]*)
|
||||
# 非数字直接当作手动输入的模型 ID
|
||||
OC_PICKED_MODEL="$choice"
|
||||
;;
|
||||
*)
|
||||
OC_PICKED_MODEL="$(printf '%s\n' "$list" | sed -n "${choice}p" | cut -f1)"
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -n "$OC_PICKED_MODEL" ] || OC_PICKED_MODEL="$fallback"
|
||||
[ -n "$OC_PICKED_MODEL" ]
|
||||
}
|
||||
|
||||
# ── JSON 读写 (使用 Node.js) ──
|
||||
json_get() {
|
||||
if [ ! -f "$CONFIG_FILE" ]; then echo ""; return; fi
|
||||
_JS_KEY="$1" "$NODE_BIN" -e "
|
||||
const fs=require('fs');
|
||||
_OC_JS_KEY="$1" _OC_JS_CONFIG="$CONFIG_FILE" "$NODE_BIN" -e '
|
||||
const fs=require("fs");
|
||||
try{
|
||||
const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8'));
|
||||
const ks=process.env._JS_KEY.split('.');let v=d;
|
||||
for(const k of ks){v=v[k];if(v===undefined){console.log('');process.exit(0);}}
|
||||
if(typeof v==='object')console.log(JSON.stringify(v));else console.log(v);
|
||||
}catch(e){console.log('');}
|
||||
" 2>/dev/null
|
||||
const d=JSON.parse(fs.readFileSync(process.env._OC_JS_CONFIG,"utf8"));
|
||||
const ks=process.env._OC_JS_KEY.split(".");let v=d;
|
||||
for(const k of ks){v=v[k];if(v===undefined||v===null){console.log("");process.exit(0);}}
|
||||
if(typeof v==="object")console.log(JSON.stringify(v));else console.log(v);
|
||||
}catch(e){console.log("");}
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# ── 配置写入类型表 ──
|
||||
# 依据: OpenClaw 官方 schema,基线见 tests/fixtures/openclaw-schema-types.tsv
|
||||
#
|
||||
# 上游对配置类型做严格校验: gateway.port 必须是 integer、
|
||||
# acp.dispatch.enabled / channels.*.enabled 必须是 boolean。
|
||||
# 写成字符串会导致 openclaw config validate 失败,进而网关拒绝启动
|
||||
# (实测报错: gateway.port: Invalid input / acp.dispatch.enabled: Invalid input)。
|
||||
#
|
||||
# 用集中表而非在每个调用点传类型,保证现有与将来新增的调用点都默认正确。
|
||||
oc_schema_type_for_key() {
|
||||
case "$1" in
|
||||
gateway.port|gateway.handshakeTimeoutMs|gateway.channelHealthCheckMinutes)
|
||||
echo "number" ;;
|
||||
gateway.channelStaleEventThresholdMinutes|gateway.channelMaxRestartsPerHour)
|
||||
echo "number" ;;
|
||||
acp.dispatch.enabled|update.checkOnStart|plugins.enabled)
|
||||
echo "boolean" ;;
|
||||
gateway.controlUi.allowInsecureAuth)
|
||||
echo "boolean" ;;
|
||||
gateway.controlUi.dangerouslyDisableDeviceAuth)
|
||||
echo "boolean" ;;
|
||||
gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback)
|
||||
echo "boolean" ;;
|
||||
gateway.allowRealIpFallback)
|
||||
echo "boolean" ;;
|
||||
channels.*.enabled)
|
||||
echo "boolean" ;;
|
||||
*)
|
||||
echo "string" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 枚举白名单。写入前校验,避免把上游不接受的值(如 gateway.bind=all)落盘。
|
||||
oc_schema_enum_for_key() {
|
||||
case "$1" in
|
||||
gateway.bind) echo "auto lan loopback custom tailnet" ;;
|
||||
gateway.mode) echo "local remote" ;;
|
||||
gateway.auth.mode) echo "none token password trusted-proxy" ;;
|
||||
logging.level) echo "silent fatal error warn info debug trace" ;;
|
||||
tools.profile) echo "minimal coding messaging full" ;;
|
||||
models.mode) echo "merge replace" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
json_set() {
|
||||
@@ -188,53 +336,121 @@ json_set() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 步骤4: 使用临时文件传递值,避免环境变量转义问题
|
||||
# 步骤4: 解析目标类型并校验枚举
|
||||
# 第三个参数可显式指定类型 (string/number/boolean/json),
|
||||
# 未指定时按 schema 类型表自动判定。
|
||||
local val_type="${3:-}"
|
||||
[ -n "$val_type" ] || val_type="$(oc_schema_type_for_key "$key")"
|
||||
|
||||
local allowed
|
||||
allowed="$(oc_schema_enum_for_key "$key")"
|
||||
if [ -n "$allowed" ]; then
|
||||
local matched=0 a
|
||||
for a in $allowed; do
|
||||
[ "$value" = "$a" ] && { matched=1; break; }
|
||||
done
|
||||
if [ "$matched" -ne 1 ]; then
|
||||
echo "ERROR: $key 的值 '$value' 不被 OpenClaw 接受" >&2
|
||||
echo "HINT: 允许的值: $allowed" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 步骤5: 使用临时文件传递值,避免环境变量转义问题
|
||||
local tmp_val_file="/tmp/.oc_json_val_$$"
|
||||
if ! printf '%s' "$value" > "$tmp_val_file" 2>/dev/null; then
|
||||
echo "ERROR: 无法创建临时文件 $tmp_val_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 步骤5: 执行 JSON 写入
|
||||
_JS_KEY="$key" _JS_DEBUG="${OC_CONFIG_DEBUG:-0}" "$NODE_BIN" -e "
|
||||
const fs=require('fs');let d={};
|
||||
const debug=process.env._JS_DEBUG==='1';
|
||||
try{
|
||||
const content=fs.readFileSync('${CONFIG_FILE}','utf8');
|
||||
d=JSON.parse(content);
|
||||
}catch(e){
|
||||
if(debug)console.error('JSON parse warning:',e.message);
|
||||
|
||||
# 步骤6: 执行 JSON 写入
|
||||
# 与 JS 侧同样的安全要求:
|
||||
# - 配置存在但解析失败时必须中止,不能用 {} 覆盖 (否则整份配置连同
|
||||
# models.providers 里的 apiKey 一起丢失)
|
||||
# - 按 schema 类型落值,不能一律写成字符串
|
||||
# - 原子写: 临时文件 -> 回读校验 -> rename
|
||||
_OC_JS_KEY="$key" _OC_JS_TYPE="$val_type" _OC_JS_VALFILE="$tmp_val_file" \
|
||||
_OC_JS_CONFIG="$CONFIG_FILE" _OC_JS_DEBUG="${OC_CONFIG_DEBUG:-0}" "$NODE_BIN" -e '
|
||||
const fs=require("fs");
|
||||
const debug=process.env._OC_JS_DEBUG==="1";
|
||||
const target=process.env._OC_JS_CONFIG;
|
||||
let d={};
|
||||
let raw="";
|
||||
try{ raw=fs.readFileSync(target,"utf8"); }catch(e){ raw=""; }
|
||||
if(raw.trim()!==""){
|
||||
try{
|
||||
d=JSON.parse(raw);
|
||||
}catch(e){
|
||||
console.error("ERROR: 配置文件不是合法 JSON: "+e.message);
|
||||
console.error("HINT: 为避免覆盖后丢失全部配置,本次写入已中止。");
|
||||
console.error("HINT: 可尝试 openclaw doctor --fix 或 openclaw config validate 定位问题。");
|
||||
process.exit(2);
|
||||
}
|
||||
if(d===null||typeof d!=="object"||Array.isArray(d)){
|
||||
console.error("ERROR: 配置根节点必须是 JSON 对象,本次写入已中止。");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
const ks=process.env._JS_KEY.split('.');let o=d;
|
||||
const rawVal=fs.readFileSync(process.env._OC_JS_VALFILE,"utf8");
|
||||
const wantType=process.env._OC_JS_TYPE||"string";
|
||||
let v;
|
||||
if(wantType==="number"){
|
||||
v=Number(rawVal.trim());
|
||||
if(!Number.isFinite(v)){
|
||||
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要数字,收到: "+rawVal.trim());
|
||||
process.exit(3);
|
||||
}
|
||||
}else if(wantType==="boolean"){
|
||||
const t=rawVal.trim().toLowerCase();
|
||||
if(t==="true"||t==="1")v=true;
|
||||
else if(t==="false"||t==="0")v=false;
|
||||
else{
|
||||
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要 true/false,收到: "+rawVal.trim());
|
||||
process.exit(3);
|
||||
}
|
||||
}else if(wantType==="json"){
|
||||
try{ v=JSON.parse(rawVal); }catch(e){
|
||||
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要合法 JSON: "+e.message);
|
||||
process.exit(3);
|
||||
}
|
||||
}else{
|
||||
v=rawVal;
|
||||
}
|
||||
const ks=process.env._OC_JS_KEY.split(".");let o=d;
|
||||
for(let i=0;i<ks.length-1;i++){
|
||||
if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};
|
||||
if(!o[ks[i]]||typeof o[ks[i]]!=="object"||Array.isArray(o[ks[i]]))o[ks[i]]={};
|
||||
o=o[ks[i]];
|
||||
}
|
||||
// 读取值并作为字符串保存
|
||||
let v=fs.readFileSync('${tmp_val_file}','utf8');
|
||||
o[ks[ks.length-1]]=v;
|
||||
let mode=0o644;
|
||||
try{ if(fs.existsSync(target)) mode=fs.statSync(target).mode & 0o777; }catch(e){}
|
||||
const tmp=target+".tmp-"+process.pid;
|
||||
try{
|
||||
fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2));
|
||||
if(debug)console.log('JSON saved successfully');
|
||||
fs.writeFileSync(tmp,JSON.stringify(d,null,2)+"\n",{mode:0o600});
|
||||
JSON.parse(fs.readFileSync(tmp,"utf8"));
|
||||
try{ fs.chmodSync(tmp,mode); }catch(e){}
|
||||
fs.renameSync(tmp,target);
|
||||
if(debug)console.log("JSON saved successfully ("+wantType+")");
|
||||
}catch(e){
|
||||
console.error('ERROR: Failed to write config:',e.message);
|
||||
try{ if(fs.existsSync(tmp)) fs.unlinkSync(tmp); }catch(e2){}
|
||||
console.error("ERROR: Failed to write config: "+e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
" 2>&1
|
||||
' 2>&1
|
||||
local _js_rc=$?
|
||||
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$tmp_val_file" 2>/dev/null
|
||||
|
||||
# 步骤6: 检查执行结果
|
||||
|
||||
# 步骤7: 检查执行结果
|
||||
if [ $_js_rc -ne 0 ]; then
|
||||
echo "ERROR: JSON 写入失败 (exit code: $_js_rc)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 步骤7: 修复文件所有权
|
||||
|
||||
# 步骤8: 修复文件所有权
|
||||
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
|
||||
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -765,26 +981,10 @@ configure_model() {
|
||||
if [ -n "$api_key" ]; then
|
||||
auth_set_apikey openai "$api_key"
|
||||
echo ""
|
||||
echo -e " ${CYAN}可用模型:${NC}"
|
||||
echo -e " ${CYAN}a)${NC} gpt-5.2 — 最强编程与代理旗舰 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} gpt-5-mini — 高性价比推理"
|
||||
echo -e " ${CYAN}c)${NC} gpt-5-nano — 极速低成本"
|
||||
echo -e " ${CYAN}d)${NC} gpt-4.1 — 最强非推理模型"
|
||||
echo -e " ${CYAN}e)${NC} o3 — 推理模型"
|
||||
echo -e " ${CYAN}f)${NC} o4-mini — 推理轻量"
|
||||
echo -e " ${CYAN}g)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="gpt-5.2" ;;
|
||||
b) model_name="gpt-5-mini" ;;
|
||||
c) model_name="gpt-5-nano" ;;
|
||||
d) model_name="gpt-4.1" ;;
|
||||
e) model_name="o3" ;;
|
||||
f) model_name="o4-mini" ;;
|
||||
g) prompt_with_default "请输入模型名称" "gpt-5.2" model_name ;;
|
||||
*) model_name="gpt-5.2" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model openai "gpt-5.6-sol"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
register_and_set_model "openai/${model_name}"
|
||||
echo -e " ${GREEN}✅ OpenAI 已配置,活跃模型: openai/${model_name}${NC}"
|
||||
fi
|
||||
@@ -798,24 +998,10 @@ configure_model() {
|
||||
if [ -n "$api_key" ]; then
|
||||
auth_set_apikey anthropic "$api_key"
|
||||
echo ""
|
||||
echo -e " ${CYAN}可用模型:${NC}"
|
||||
echo -e " ${CYAN}a)${NC} claude-sonnet-4-20250514 — Claude Sonnet 4 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} claude-opus-4-20250514 — Claude Opus 4 顶级推理"
|
||||
echo -e " ${CYAN}c)${NC} claude-haiku-4-5 — Claude Haiku 4.5 轻量快速"
|
||||
echo -e " ${CYAN}d)${NC} claude-sonnet-4.5 — Claude Sonnet 4.5"
|
||||
echo -e " ${CYAN}e)${NC} claude-sonnet-4.6 — Claude Sonnet 4.6"
|
||||
echo -e " ${CYAN}f)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="claude-sonnet-4-20250514" ;;
|
||||
b) model_name="claude-opus-4-20250514" ;;
|
||||
c) model_name="claude-haiku-4-5" ;;
|
||||
d) model_name="claude-sonnet-4-5" ;;
|
||||
e) model_name="claude-sonnet-4-6" ;;
|
||||
f) prompt_with_default "请输入模型名称" "claude-sonnet-4-20250514" model_name ;;
|
||||
*) model_name="claude-sonnet-4-20250514" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model anthropic "claude-sonnet-5"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
register_and_set_model "anthropic/${model_name}"
|
||||
echo -e " ${GREEN}✅ Anthropic 已配置,活跃模型: anthropic/${model_name}${NC}"
|
||||
fi
|
||||
@@ -829,24 +1015,10 @@ configure_model() {
|
||||
if [ -n "$api_key" ]; then
|
||||
auth_set_apikey google "$api_key"
|
||||
echo ""
|
||||
echo -e " ${CYAN}可用模型:${NC}"
|
||||
echo -e " ${CYAN}a)${NC} gemini-2.5-pro — 旗舰推理 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} gemini-2.5-flash — 快速均衡"
|
||||
echo -e " ${CYAN}c)${NC} gemini-2.5-flash-lite — 极速低成本"
|
||||
echo -e " ${CYAN}d)${NC} gemini-3-flash-preview — Gemini 3 Flash 预览"
|
||||
echo -e " ${CYAN}e)${NC} gemini-3-pro-preview — Gemini 3 Pro 预览"
|
||||
echo -e " ${CYAN}f)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="gemini-2.5-pro" ;;
|
||||
b) model_name="gemini-2.5-flash" ;;
|
||||
c) model_name="gemini-2.5-flash-lite" ;;
|
||||
d) model_name="gemini-3-flash-preview" ;;
|
||||
e) model_name="gemini-3-pro-preview" ;;
|
||||
f) prompt_with_default "请输入模型名称" "gemini-2.5-pro" model_name ;;
|
||||
*) model_name="gemini-2.5-pro" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model google "gemini-2.5-pro"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
register_and_set_model "google/${model_name}"
|
||||
echo -e " ${GREEN}✅ Google Gemini 已配置,活跃模型: google/${model_name}${NC}"
|
||||
fi
|
||||
@@ -861,26 +1033,10 @@ configure_model() {
|
||||
if [ -n "$api_key" ]; then
|
||||
auth_set_apikey openrouter "$api_key"
|
||||
echo ""
|
||||
echo -e " ${CYAN}常用模型 (格式: provider/model):${NC}"
|
||||
echo -e " ${CYAN}a)${NC} anthropic/claude-sonnet-4 — Claude Sonnet 4 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} anthropic/claude-opus-4 — Claude Opus 4"
|
||||
echo -e " ${CYAN}c)${NC} openai/gpt-5.2 — GPT-5.2"
|
||||
echo -e " ${CYAN}d)${NC} google/gemini-2.5-pro — Gemini 2.5 Pro"
|
||||
echo -e " ${CYAN}e)${NC} deepseek/deepseek-r1 — DeepSeek R1"
|
||||
echo -e " ${CYAN}f)${NC} meta-llama/llama-4-maverick — Meta Llama 4"
|
||||
echo -e " ${CYAN}g)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="anthropic/claude-sonnet-4" ;;
|
||||
b) model_name="anthropic/claude-opus-4" ;;
|
||||
c) model_name="openai/gpt-5.2" ;;
|
||||
d) model_name="google/gemini-2.5-pro" ;;
|
||||
e) model_name="deepseek/deepseek-r1" ;;
|
||||
f) model_name="meta-llama/llama-4-maverick" ;;
|
||||
g) prompt_with_default "请输入模型名称" "anthropic/claude-sonnet-4" model_name ;;
|
||||
*) model_name="anthropic/claude-sonnet-4" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model openrouter "auto"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
register_and_set_model "openrouter/${model_name}"
|
||||
echo -e " ${GREEN}✅ OpenRouter 已配置,活跃模型: openrouter/${model_name}${NC}"
|
||||
fi
|
||||
@@ -900,40 +1056,10 @@ configure_model() {
|
||||
echo -e " ${CYAN}选择默认模型:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}── GPT 系列 ──${NC}"
|
||||
echo -e " ${CYAN}a)${NC} github-copilot/gpt-4.1 — GPT-4.1 ${GREEN}(推荐)${NC}"
|
||||
echo -e " ${CYAN}b)${NC} github-copilot/gpt-4o — GPT-4o"
|
||||
echo -e " ${CYAN}c)${NC} github-copilot/gpt-5 — GPT-5"
|
||||
echo -e " ${CYAN}d)${NC} github-copilot/gpt-5-mini — GPT-5 mini"
|
||||
echo -e " ${CYAN}e)${NC} github-copilot/gpt-5.1 — GPT-5.1"
|
||||
echo -e " ${CYAN}f)${NC} github-copilot/gpt-5.2 — GPT-5.2"
|
||||
echo -e " ${CYAN}g)${NC} github-copilot/gpt-5.2-codex — GPT-5.2 Codex"
|
||||
echo ""
|
||||
echo -e " ${CYAN}── Claude 系列 ──${NC}"
|
||||
echo -e " ${CYAN}h)${NC} github-copilot/claude-sonnet-4 — Claude Sonnet 4"
|
||||
echo -e " ${CYAN}i)${NC} github-copilot/claude-sonnet-4.5 — Claude Sonnet 4.5"
|
||||
echo -e " ${CYAN}j)${NC} github-copilot/claude-sonnet-4.6 — Claude Sonnet 4.6"
|
||||
echo ""
|
||||
echo -e " ${CYAN}── Gemini 系列 ──${NC}"
|
||||
echo -e " ${CYAN}k)${NC} github-copilot/gemini-2.5-pro — Gemini 2.5 Pro"
|
||||
echo ""
|
||||
echo -e " ${CYAN}m)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="github-copilot/gpt-4.1" ;;
|
||||
b) model_name="github-copilot/gpt-4o" ;;
|
||||
c) model_name="github-copilot/gpt-5" ;;
|
||||
d) model_name="github-copilot/gpt-5-mini" ;;
|
||||
e) model_name="github-copilot/gpt-5.1" ;;
|
||||
f) model_name="github-copilot/gpt-5.2" ;;
|
||||
g) model_name="github-copilot/gpt-5.2-codex" ;;
|
||||
h) model_name="github-copilot/claude-sonnet-4" ;;
|
||||
i) model_name="github-copilot/claude-sonnet-4.5" ;;
|
||||
j) model_name="github-copilot/claude-sonnet-4.6" ;;
|
||||
k) model_name="github-copilot/gemini-2.5-pro" ;;
|
||||
m) prompt_with_default "请输入模型名称" "github-copilot/gpt-4.1" model_name ;;
|
||||
*) model_name="github-copilot/gpt-4.1" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model github-copilot "gpt-5.5"
|
||||
model_name="github-copilot/$OC_PICKED_MODEL"
|
||||
register_and_set_model "$model_name"
|
||||
echo -e " ${GREEN}✅ 活跃模型已设置: ${model_name}${NC}"
|
||||
else
|
||||
@@ -973,50 +1099,10 @@ configure_model() {
|
||||
if [ -n "$api_key" ]; then
|
||||
echo ""
|
||||
echo -e " ${CYAN}── 千问商业版 ──${NC}"
|
||||
echo -e " ${CYAN}a)${NC} qwen-max — 千问Max 旗舰模型 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} qwen-plus — 千问Plus 均衡之选 (已升级Qwen3.5)"
|
||||
echo -e " ${CYAN}c)${NC} qwen-flash — 千问Flash 速度最快 (已升级Qwen3.5)"
|
||||
echo -e " ${CYAN}d)${NC} qwen-turbo — 千问Turbo 经济实惠"
|
||||
echo -e " ${CYAN}e)${NC} qwen-long — 千问Long 超长上下文 (1000万Token)"
|
||||
echo -e " ${CYAN}── 千问Coder ──${NC}"
|
||||
echo -e " ${CYAN}f)${NC} qwen3-coder-plus — 代码专用旗舰 (100万上下文)"
|
||||
echo -e " ${CYAN}g)${NC} qwen3-coder-flash — 代码专用极速"
|
||||
echo -e " ${CYAN}── 推理模型 ──${NC}"
|
||||
echo -e " ${CYAN}h)${NC} qwq-plus — QwQ推理模型 (数学/代码强化)"
|
||||
echo -e " ${CYAN}── 千问开源版 ──${NC}"
|
||||
echo -e " ${CYAN}i)${NC} qwen3-235b-a22b — Qwen3 235B MoE"
|
||||
echo -e " ${CYAN}j)${NC} qwen3-32b — Qwen3 32B"
|
||||
echo -e " ${CYAN}k)${NC} qwen3-30b-a3b — Qwen3 30B MoE"
|
||||
echo -e " ${CYAN}── 第三方模型 ──${NC}"
|
||||
echo -e " ${CYAN}l)${NC} deepseek-r1 — DeepSeek R1 推理"
|
||||
echo -e " ${CYAN}m)${NC} deepseek-v3 — DeepSeek V3"
|
||||
echo -e " ${CYAN}n)${NC} kimi-k2.5 — Kimi K2.5"
|
||||
echo -e " ${CYAN}o)${NC} glm-5 — 智谱 GLM-5"
|
||||
echo -e " ${CYAN}p)${NC} MiniMax-M2.5 — MiniMax M2.5"
|
||||
echo -e " ${CYAN}────────────${NC}"
|
||||
echo -e " ${CYAN}z)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="qwen-max" ;;
|
||||
b) model_name="qwen-plus" ;;
|
||||
c) model_name="qwen-flash" ;;
|
||||
d) model_name="qwen-turbo" ;;
|
||||
e) model_name="qwen-long" ;;
|
||||
f) model_name="qwen3-coder-plus" ;;
|
||||
g) model_name="qwen3-coder-flash" ;;
|
||||
h) model_name="qwq-plus" ;;
|
||||
i) model_name="qwen3-235b-a22b" ;;
|
||||
j) model_name="qwen3-32b" ;;
|
||||
k) model_name="qwen3-30b-a3b" ;;
|
||||
l) model_name="deepseek-r1" ;;
|
||||
m) model_name="deepseek-v3" ;;
|
||||
n) model_name="kimi-k2.5" ;;
|
||||
o) model_name="glm-5" ;;
|
||||
p) model_name="MiniMax-M2.5" ;;
|
||||
z) prompt_with_default "请输入模型名称" "qwen-max" model_name ;;
|
||||
*) model_name="qwen-max" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model qwen "qwen3.5-plus"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
auth_set_apikey dashscope "$api_key"
|
||||
register_custom_provider dashscope "https://dashscope.aliyuncs.com/compatible-mode/v1" "$api_key" "$model_name" "$model_name"
|
||||
register_and_set_model "dashscope/${model_name}"
|
||||
@@ -1075,26 +1161,10 @@ configure_model() {
|
||||
prompt_with_default "请输入 xAI API Key" "" api_key
|
||||
if [ -n "$api_key" ]; then
|
||||
echo ""
|
||||
echo -e " ${CYAN}可用模型:${NC}"
|
||||
echo -e " ${CYAN}a)${NC} grok-4 — Grok 4 旗舰 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} grok-4-fast — Grok 4 Fast"
|
||||
echo -e " ${CYAN}c)${NC} grok-3 — Grok 3"
|
||||
echo -e " ${CYAN}d)${NC} grok-3-fast — Grok 3 Fast"
|
||||
echo -e " ${CYAN}e)${NC} grok-3-mini — Grok 3 Mini"
|
||||
echo -e " ${CYAN}f)${NC} grok-3-mini-fast — Grok 3 Mini Fast"
|
||||
echo -e " ${CYAN}g)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="grok-4" ;;
|
||||
b) model_name="grok-4-fast" ;;
|
||||
c) model_name="grok-3" ;;
|
||||
d) model_name="grok-3-fast" ;;
|
||||
e) model_name="grok-3-mini" ;;
|
||||
f) model_name="grok-3-mini-fast" ;;
|
||||
g) prompt_with_default "请输入模型名称" "grok-4" model_name ;;
|
||||
*) model_name="grok-4" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model xai "grok-4.3"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
auth_set_apikey xai "$api_key"
|
||||
register_and_set_model "xai/${model_name}"
|
||||
echo -e " ${GREEN}✅ xAI Grok 已配置,活跃模型: xai/${model_name}${NC}"
|
||||
@@ -1117,30 +1187,15 @@ configure_model() {
|
||||
echo -e " ${CYAN}── 非Pro模型 (支持代金券/免费额度) ──${NC}"
|
||||
echo -e " ${CYAN}1)${NC} deepseek-ai/DeepSeek-V3 — DeepSeek-V3 (推荐)"
|
||||
echo -e " ${CYAN}2)${NC} deepseek-ai/DeepSeek-R1 — DeepSeek-R1 (推理模型)"
|
||||
echo -e " ${CYAN}3)${NC} Qwen/Qwen2.5-72B-Instruct — 通义千问 2.5 72B"
|
||||
echo -e " ${CYAN}4)${NC} Qwen/Qwen2.5-7B-Instruct — 通义千问 2.5 7B"
|
||||
echo -e " ${CYAN}5)${NC} THUDM/glm-4-9b-chat — 智谱 GLM-4 9B"
|
||||
echo -e " ${CYAN}6)${NC} 01-ai/Yi-1.5-34B-Chat-16K — 零一万物 Yi-1.5 34B"
|
||||
# SiliconFlow 不是 OpenClaw 内置/官方插件 provider (走 OpenAI 兼容接入),
|
||||
# 无法用上游 catalog 核实模型 ID。原硬编码清单里的 Qwen2.5-7B/72B、
|
||||
# Yi-1.5-34B-Chat-16K、glm-4-9b-chat 均已明显过期,因此不再维护,
|
||||
# 改为引导用户从官方模型广场复制当前 ID。
|
||||
echo -e " ${CYAN}请从官方模型广场复制模型 ID:${NC}"
|
||||
echo -e " ${CYAN}https://cloud.siliconflow.cn/models${NC}"
|
||||
echo -e " ${DIM}格式形如 deepseek-ai/DeepSeek-V3.2;Pro/ 前缀模型仅支持充值余额${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}── Pro模型 (仅支持充值余额) ──${NC}"
|
||||
echo -e " ${CYAN}7)${NC} Pro/deepseek-ai/DeepSeek-V3 — DeepSeek-V3 (Pro增强侧)"
|
||||
echo -e " ${CYAN}8)${NC} Pro/zai-org/GLM-5 — 智谱 GLM-5"
|
||||
echo ""
|
||||
echo -e " ${CYAN}0)${NC} 手动输入其他任意模型名称"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型 [0-8]" "1" model_choice
|
||||
case "$model_choice" in
|
||||
1) model_name="deepseek-ai/DeepSeek-V3" ;;
|
||||
2) model_name="deepseek-ai/DeepSeek-R1" ;;
|
||||
3) model_name="Qwen/Qwen2.5-72B-Instruct" ;;
|
||||
4) model_name="Qwen/Qwen2.5-7B-Instruct" ;;
|
||||
5) model_name="THUDM/glm-4-9b-chat" ;;
|
||||
6) model_name="01-ai/Yi-1.5-34B-Chat-16K" ;;
|
||||
7) model_name="Pro/deepseek-ai/DeepSeek-V3" ;;
|
||||
8) model_name="Pro/zai-org/GLM-5" ;;
|
||||
0) prompt_with_default "请输入模型详细名称" "deepseek-ai/DeepSeek-V3" model_name ;;
|
||||
*) model_name="deepseek-ai/DeepSeek-V3" ;;
|
||||
esac
|
||||
prompt_with_default "请输入模型 ID" "" model_name
|
||||
auth_set_apikey siliconflow "$api_key"
|
||||
register_custom_provider siliconflow "https://api.siliconflow.cn/v1" "$api_key" "$model_name" "$model_name"
|
||||
register_and_set_model "siliconflow/${model_name}"
|
||||
@@ -1276,33 +1331,10 @@ configure_model() {
|
||||
echo ""
|
||||
echo -e " ${CYAN}可用模型 (Coding Plan 套餐内):${NC}"
|
||||
echo -e " ${CYAN}── 智能推荐 ──${NC}"
|
||||
echo -e " ${CYAN}a)${NC} tc-code-latest — 自动路由 (由平台选择最佳模型) ${GREEN}★ 推荐${NC}"
|
||||
echo -e " ${CYAN}── 推理模型 ──${NC}"
|
||||
echo -e " ${CYAN}b)${NC} hunyuan-t1 — 混元 T1 深度推理"
|
||||
echo -e " ${CYAN}c)${NC} hunyuan-2.0-thinking — 混元 2.0 Thinking"
|
||||
echo -e " ${CYAN}── 旗舰模型 ──${NC}"
|
||||
echo -e " ${CYAN}d)${NC} hunyuan-turbos — 混元 TurboS 旗舰"
|
||||
echo -e " ${CYAN}e)${NC} hunyuan-2.0-instruct — 混元 2.0 Instruct"
|
||||
echo -e " ${CYAN}── 第三方模型 ──${NC}"
|
||||
echo -e " ${CYAN}f)${NC} glm-5 — 智谱 GLM-5"
|
||||
echo -e " ${CYAN}g)${NC} kimi-k2.5 — Moonshot Kimi K2.5"
|
||||
echo -e " ${CYAN}h)${NC} minimax-m2.5 — MiniMax M2.5"
|
||||
echo -e " ${CYAN}────────────${NC}"
|
||||
echo -e " ${CYAN}z)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择默认模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="tc-code-latest" ;;
|
||||
b) model_name="hunyuan-t1" ;;
|
||||
c) model_name="hunyuan-2.0-thinking" ;;
|
||||
d) model_name="hunyuan-turbos" ;;
|
||||
e) model_name="hunyuan-2.0-instruct" ;;
|
||||
f) model_name="glm-5" ;;
|
||||
g) model_name="kimi-k2.5" ;;
|
||||
h) model_name="minimax-m2.5" ;;
|
||||
z) prompt_with_default "请输入模型名称" "tc-code-latest" model_name ;;
|
||||
*) model_name="tc-code-latest" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model tencent-tokenhub "hy3"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
echo ""
|
||||
echo -e " ${CYAN}正在注册腾讯云 Coding Plan 提供商 (含全部套餐模型)...${NC}"
|
||||
auth_set_apikey lkeap "$api_key"
|
||||
@@ -1324,21 +1356,10 @@ configure_model() {
|
||||
auth_set_apikey qianfan "$api_key"
|
||||
echo ""
|
||||
echo -e " ${CYAN}可用模型:${NC}"
|
||||
echo -e " ${CYAN}a)${NC} ernie-4.0-8k — 文心一言 4.0 (推荐)"
|
||||
echo -e " ${CYAN}b)${NC} ernie-3.5-8k — 文心一言 3.5"
|
||||
echo -e " ${CYAN}c)${NC} ernie-4.0-turbo-8k — 文心一言 4.0 Turbo"
|
||||
echo -e " ${CYAN}d)${NC} ernie-speed-8k — 文心一言 Speed 极速"
|
||||
echo -e " ${CYAN}e)${NC} 手动输入模型名"
|
||||
echo ""
|
||||
prompt_with_default "请选择模型" "a" model_choice
|
||||
case "$model_choice" in
|
||||
a) model_name="ernie-4.0-8k" ;;
|
||||
b) model_name="ernie-3.5-8k" ;;
|
||||
c) model_name="ernie-4.0-turbo-8k" ;;
|
||||
d) model_name="ernie-speed-8k" ;;
|
||||
e) prompt_with_default "请输入模型名称" "ernie-4.0-8k" model_name ;;
|
||||
*) model_name="ernie-4.0-8k" ;;
|
||||
esac
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model qianfan "ernie-5.0-thinking-preview"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
# 百度千帆使用 OpenAI 兼容接口
|
||||
# 注: OpenClaw v2026.3.28+ 支持 qianfan 原生 provider
|
||||
register_custom_provider qianfan "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop" "$api_key" "$model_name" "$model_name"
|
||||
@@ -1382,25 +1403,10 @@ configure_model() {
|
||||
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
|
||||
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
|
||||
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
|
||||
oc_pick_model zai "glm-5.2"
|
||||
model_name="$OC_PICKED_MODEL"
|
||||
# 智谱 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"
|
||||
@@ -1431,7 +1437,7 @@ configure_model() {
|
||||
echo ""
|
||||
prompt_with_default "API Base URL (如 https://api.anthropic.com)" "" base_url
|
||||
prompt_with_default "API Key" "" api_key
|
||||
prompt_with_default "模型名称" "claude-sonnet-4-20250514" model_name
|
||||
prompt_with_default "模型名称" "claude-sonnet-5" model_name
|
||||
if [ -n "$base_url" ] && [ -n "$api_key" ] && [ -n "$model_name" ]; then
|
||||
_ACP_URL="${base_url%/}" _ACP_KEY="$api_key" _ACP_MID="$model_name" "$NODE_BIN" -e "
|
||||
const fs=require('fs');
|
||||
@@ -2345,7 +2351,7 @@ reset_to_defaults() {
|
||||
echo -e " ${CYAN}已取消${NC}"
|
||||
fi
|
||||
;;
|
||||
c)
|
||||
4)
|
||||
echo ""
|
||||
echo -e " ${RED}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e " ${RED}║ ⚠️ 完全恢复出厂设置 ║${NC}"
|
||||
@@ -2527,7 +2533,7 @@ backup_restore_menu() {
|
||||
oc_cmd backup verify "$latest" 2>&1
|
||||
fi
|
||||
;;
|
||||
c)
|
||||
4)
|
||||
echo ""
|
||||
if [ -d "$backup_dir" ]; then
|
||||
local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | wc -l)
|
||||
@@ -2545,7 +2551,7 @@ backup_restore_menu() {
|
||||
echo ""
|
||||
echo -e " ${DIM}备份目录: ${backup_dir}${NC}"
|
||||
;;
|
||||
d)
|
||||
5)
|
||||
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
|
||||
if [ -z "$latest" ]; then
|
||||
echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}"
|
||||
@@ -2713,7 +2719,9 @@ advanced_menu() {
|
||||
gw_port=$(json_get "gateway.port" 2>/dev/null || echo "18789")
|
||||
gw_bind=$(json_get "gateway.bind" 2>/dev/null || echo "lan")
|
||||
gw_mode=$(json_get "gateway.mode" 2>/dev/null || echo "local")
|
||||
log_level=$(json_get "gateway.logLevel" 2>/dev/null || echo "")
|
||||
# 读 logging.level (正确键);兼容旧配置里残留的 gateway.logLevel 以便显示
|
||||
log_level=$(json_get "logging.level" 2>/dev/null || echo "")
|
||||
[ -n "$log_level" ] || log_level=$(json_get "gateway.logLevel" 2>/dev/null || echo "")
|
||||
acp_dispatch=$(json_get "acp.dispatch.enabled" 2>/dev/null || echo "false")
|
||||
|
||||
echo ""
|
||||
@@ -2738,32 +2746,57 @@ advanced_menu() {
|
||||
echo ""
|
||||
prompt_with_default "请输入 Gateway 端口" "$gw_port" new_port
|
||||
if [ -n "$new_port" ] && [ "$new_port" != "$gw_port" ]; then
|
||||
json_set "gateway.port" "$new_port"
|
||||
# 同步到 UCI
|
||||
uci set openclaw.main.port="$new_port" 2>/dev/null
|
||||
uci commit openclaw 2>/dev/null
|
||||
echo -e " ${GREEN}✅ 端口已设置为 ${new_port}${NC}"
|
||||
ask_restart
|
||||
# 端口必须是 1-65535 的整数,先自行校验再写入,
|
||||
# 避免把非法值交给 json_set 后只得到一条底层报错。
|
||||
case "$new_port" in
|
||||
''|*[!0-9]*)
|
||||
echo -e " ${YELLOW}无效端口: 必须是数字${NC}"
|
||||
;;
|
||||
*)
|
||||
if [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
|
||||
echo -e " ${YELLOW}无效端口: 需在 1-65535 之间${NC}"
|
||||
elif json_set "gateway.port" "$new_port"; then
|
||||
# 仅在 JSON 写入成功后才同步 UCI,避免两边不一致
|
||||
uci set openclaw.main.port="$new_port" 2>/dev/null
|
||||
uci commit openclaw 2>/dev/null
|
||||
echo -e " ${GREEN}✅ 端口已设置为 ${new_port}${NC}"
|
||||
ask_restart
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
echo ""
|
||||
# 取值必须与 OpenClaw schema 的 gateway.bind 枚举一致:
|
||||
# auto/lan/loopback/custom/tailnet。历史上这里提供的 all
|
||||
# 不被上游接受 (实测: gateway.bind: Invalid input),
|
||||
# 想监听所有接口应使用 custom + customBindHost。
|
||||
echo -e " ${CYAN}绑定地址选项:${NC}"
|
||||
echo " lan - 仅 LAN 接口 (推荐)"
|
||||
echo " loopback - 仅本机访问"
|
||||
echo " all - 所有接口 (0.0.0.0)"
|
||||
echo " auto - 自动选择"
|
||||
echo " custom - 自定义地址 (可配合 0.0.0.0 监听所有接口)"
|
||||
echo " tailnet - Tailscale 网络"
|
||||
echo ""
|
||||
prompt_with_default "请输入绑定地址" "$gw_bind" new_bind
|
||||
if [ -n "$new_bind" ]; then
|
||||
# 兼容旧值: 用户/旧配置里的 all 等价于 custom + 0.0.0.0
|
||||
if [ "$new_bind" = "all" ]; then
|
||||
echo -e " ${YELLOW}提示: OpenClaw 已不接受 all,已改用 custom + 0.0.0.0${NC}"
|
||||
new_bind="custom"
|
||||
json_set "gateway.customBindHost" "0.0.0.0" || true
|
||||
fi
|
||||
case "$new_bind" in
|
||||
lan|loopback|all)
|
||||
json_set "gateway.bind" "$new_bind"
|
||||
uci set openclaw.main.bind="$new_bind" 2>/dev/null
|
||||
uci commit openclaw 2>/dev/null
|
||||
echo -e " ${GREEN}✅ 绑定地址已设置为 ${new_bind}${NC}"
|
||||
ask_restart
|
||||
auto|lan|loopback|custom|tailnet)
|
||||
if json_set "gateway.bind" "$new_bind"; then
|
||||
uci set openclaw.main.bind="$new_bind" 2>/dev/null
|
||||
uci commit openclaw 2>/dev/null
|
||||
echo -e " ${GREEN}✅ 绑定地址已设置为 ${new_bind}${NC}"
|
||||
ask_restart
|
||||
fi
|
||||
;;
|
||||
*) echo -e " ${YELLOW}无效选项${NC}" ;;
|
||||
*) echo -e " ${YELLOW}无效选项 (允许: auto/lan/loopback/custom/tailnet)${NC}" ;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
@@ -2775,21 +2808,27 @@ advanced_menu() {
|
||||
echo ""
|
||||
prompt_with_default "请输入运行模式" "$gw_mode" new_mode
|
||||
if [ -n "$new_mode" ] && [ "$new_mode" != "$gw_mode" ]; then
|
||||
json_set "gateway.mode" "$new_mode"
|
||||
echo -e " ${GREEN}✅ 运行模式已设置为 ${new_mode}${NC}"
|
||||
ask_restart
|
||||
if json_set "gateway.mode" "$new_mode"; then
|
||||
echo -e " ${GREEN}✅ 运行模式已设置为 ${new_mode}${NC}"
|
||||
ask_restart
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
4)
|
||||
echo ""
|
||||
# 正确键是顶层 logging.level。gateway.logLevel 不存在于
|
||||
# OpenClaw schema (gateway.additionalProperties=false),
|
||||
# 实测 config set 报 Unrecognized key,手写进文件则被静默忽略,
|
||||
# 表现为"界面显示已设置但从未生效"。
|
||||
echo -e " ${CYAN}日志级别选项:${NC}"
|
||||
echo " debug, info, warn, error"
|
||||
echo " silent, fatal, error, warn, info, debug, trace"
|
||||
echo ""
|
||||
prompt_with_default "请输入日志级别" "${log_level:-info}" new_level
|
||||
if [ -n "$new_level" ]; then
|
||||
json_set "gateway.logLevel" "$new_level"
|
||||
echo -e " ${GREEN}✅ 日志级别已设置为 ${new_level}${NC}"
|
||||
ask_restart
|
||||
if json_set "logging.level" "$new_level"; then
|
||||
echo -e " ${GREEN}✅ 日志级别已设置为 ${new_level}${NC}"
|
||||
ask_restart
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
5)
|
||||
@@ -2801,11 +2840,12 @@ advanced_menu() {
|
||||
prompt_with_default "请输入设置" "$acp_dispatch" new_acp
|
||||
case "$new_acp" in
|
||||
true|false)
|
||||
json_set "acp.dispatch.enabled" "$new_acp"
|
||||
echo -e " ${GREEN}✅ ACP Dispatch 已设置为 ${new_acp}${NC}"
|
||||
ask_restart
|
||||
if json_set "acp.dispatch.enabled" "$new_acp"; then
|
||||
echo -e " ${GREEN}✅ ACP Dispatch 已设置为 ${new_acp}${NC}"
|
||||
ask_restart
|
||||
fi
|
||||
;;
|
||||
*) echo -e " ${YELLOW}无效选项${NC}" ;;
|
||||
*) echo -e " ${YELLOW}无效选项 (只接受 true / false)${NC}" ;;
|
||||
esac
|
||||
;;
|
||||
6)
|
||||
@@ -2891,11 +2931,19 @@ case "${1:-}" in
|
||||
;;
|
||||
--set)
|
||||
if [ -n "${2:-}" ] && [ -n "${3:-}" ]; then
|
||||
json_set "$2" "$3"
|
||||
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ 已设置 $2${NC}"
|
||||
# 必须检查 json_set 的返回码: 类型/枚举校验失败或配置损坏时
|
||||
# 写入会被拒绝,此时不能再报告成功 (否则就是"界面显示已设置、
|
||||
# 实际未生效"的假成功)。
|
||||
if json_set "$2" "$3"; then
|
||||
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ 已设置 $2${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 设置失败: $2${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "用法: oc-config.sh --set <key> <value>"
|
||||
echo "用法: oc-config.sh --set <key> <value> [类型]"
|
||||
echo "类型可选: string | number | boolean | json (默认按 schema 自动判定)"
|
||||
fi
|
||||
;;
|
||||
--get)
|
||||
|
||||
@@ -131,7 +131,19 @@
|
||||
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const ptyToken = urlParams.get('pty_token') || '';
|
||||
// 取到 token 后立刻从地址栏移除: 否则它会留在浏览器历史、
|
||||
// 以及任何 Referer/截图里。内存中的 ptyToken 仍可用于建连。
|
||||
if (ptyToken && window.history && history.replaceState) {
|
||||
try {
|
||||
const clean = new URL(location.href);
|
||||
clean.searchParams.delete('pty_token');
|
||||
history.replaceState(null, '', clean.pathname + (clean.search || '') + clean.hash);
|
||||
} catch (e) {}
|
||||
}
|
||||
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
|
||||
// 展示用 URL: 隐去 token。真实 wsUrl 只用于建立连接,
|
||||
// 不写入页面文本或 console —— 否则 token 会留在截图、录屏与浏览器历史里。
|
||||
const wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
let retryCount = 0;
|
||||
@@ -142,8 +154,8 @@
|
||||
|
||||
console.log('[oc-config] protocol:', location.protocol);
|
||||
console.log('[oc-config] host:', location.host, 'port:', location.port);
|
||||
console.log('[oc-config] WebSocket URL:', wsUrl);
|
||||
loadingDebug.textContent = wsUrl;
|
||||
console.log('[oc-config] WebSocket URL:', wsUrlSafe);
|
||||
loadingDebug.textContent = wsUrlSafe;
|
||||
|
||||
function connect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
@@ -151,11 +163,11 @@
|
||||
if (ws) { try { ws.close(); } catch(e){} ws = null; }
|
||||
|
||||
retryCount++;
|
||||
console.log('[oc-config] Connecting to', wsUrl, '(attempt', retryCount + ')');
|
||||
console.log('[oc-config] Connecting to', wsUrlSafe, '(attempt', retryCount + ')');
|
||||
loadingMsg.textContent = retryCount > 1
|
||||
? '正在重新连接... (第 ' + retryCount + ' 次)'
|
||||
: '正在连接终端...';
|
||||
loadingDebug.textContent = wsUrl;
|
||||
loadingDebug.textContent = wsUrlSafe;
|
||||
|
||||
// 先做一个 HTTP 预检确认服务器可达
|
||||
fetch('/health').then(function(r) {
|
||||
|
||||
@@ -75,10 +75,33 @@ function getMimeType(ext) {
|
||||
return types[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// 供 LuCI 页面以 iframe 嵌入本服务所需的响应头。
|
||||
//
|
||||
// 原实现是 ACAO:* + X-Frame-Options:ALLOWALL + CSP default-src:*,
|
||||
// 等于对任意站点开放。收紧依据(已逐项核实):
|
||||
// - 页面内所有资源都是本地的 /lib/*.js|css,无任何外链 -> default-src 可收到 'self'
|
||||
// - 唯一的 fetch 是 /health,与 iframe 自身同源 -> 不需要 Access-Control-Allow-Origin
|
||||
// - 存在内联 <style> 与 <script> 块 -> 必须保留 'unsafe-inline'
|
||||
// - xterm.js 不需要 eval -> 去掉 'unsafe-eval'
|
||||
// - LuCI 与本服务端口不同(80 vs 18793)属跨源嵌入,
|
||||
// 跨源 iframe 只需 frame-ancestors 放行,与 CORS 无关
|
||||
//
|
||||
// frame-ancestors 无法在此处限定到具体 LAN 地址(服务端拿不到父页面 origin,
|
||||
// 且用户访问 LuCI 的主机名/IP 各不相同),因此保留通配但去掉 X-Frame-Options:
|
||||
// 两者同时存在时旧浏览器会以更宽松的 ALLOWALL 为准。
|
||||
// WebSocket 升级仍由 token 校验保护(无 token / 错误 token 均返回 403)。
|
||||
const IFRAME_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Frame-Options': 'ALLOWALL',
|
||||
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' ws: wss:",
|
||||
'frame-ancestors *',
|
||||
].join('; '),
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
};
|
||||
|
||||
// ── WebSocket 帧处理 (RFC 6455) ──
|
||||
@@ -269,11 +292,12 @@ function handleRequest(req, res) {
|
||||
let fp = url.pathname;
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
|
||||
// 本服务只被同源页面 fetch(/health),不需要放开 CORS
|
||||
res.writeHead(204, { 'Allow': 'GET, OPTIONS' });
|
||||
return res.end();
|
||||
}
|
||||
if (fp === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
|
||||
}
|
||||
if (fp === '/' || fp === '') fp = '/index.html';
|
||||
|
||||
Reference in new issue
Block a user