diff --git a/NODE_VERSION b/NODE_VERSION
index 5ecee25..6bb9005 100644
--- a/NODE_VERSION
+++ b/NODE_VERSION
@@ -1 +1 @@
-v22.22.1
+v22.22.3
diff --git a/portable/NODE_VERSION b/portable/NODE_VERSION
new file mode 100644
index 0000000..6bb9005
--- /dev/null
+++ b/portable/NODE_VERSION
@@ -0,0 +1 @@
+v22.22.3
diff --git a/portable/config-server/public/index.html b/portable/config-server/public/index.html
index dc00b6d..5c784f7 100644
--- a/portable/config-server/public/index.html
+++ b/portable/config-server/public/index.html
@@ -59,7 +59,9 @@ h1 .lobster { color: #ff6b35; }
}
.form-group input,
input[type="text"],
-input[type="password"] {
+input[type="password"],
+.form-group select,
+select {
display: block;
width: 100%;
min-width: 0;
@@ -74,6 +76,20 @@ input[type="password"] {
outline: none;
transition: border-color 0.2s;
}
+.form-group select,
+select {
+ padding-right: 36px;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+}
+.form-group select:focus,
+select:focus { border-color: #ff6b35; }
+.form-group select option,
+select option { background: #222; color: #fff; }
input:focus { border-color: #ff6b35; }
.input-wrap { position: relative; display: block; width: 100%; min-width: 0; }
.input-wrap input { padding-right: 70px; }
@@ -114,6 +130,37 @@ input:focus { border-color: #ff6b35; }
.btn-secondary:hover { background: #444; }
.btn-row { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
+/* Key verified: model picker + continue on one row */
+.key-actions {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ margin-top: 16px;
+ margin-bottom: 4px;
+}
+.key-actions .model-picker {
+ flex: 1;
+ min-width: 0;
+ margin-bottom: 0;
+}
+.key-actions .model-picker label {
+ margin-bottom: 6px;
+ font-size: 0.85em;
+ color: #aaa;
+}
+.key-actions .btn-primary {
+ flex-shrink: 0;
+ min-width: 128px;
+ min-height: 46px;
+ padding: 12px 22px;
+ margin-left: auto;
+}
+.key-actions.has-model .btn-primary { margin-left: 0; }
+@media (max-width: 520px) {
+ .key-actions { flex-direction: column; align-items: stretch; }
+ .key-actions .btn-primary { margin-left: 0; width: 100%; }
+}
+
/* Result */
.result { text-align: center; padding: 40px 20px; }
.result .icon { font-size: 3em; margin-bottom: 12px; }
@@ -126,15 +173,29 @@ input:focus { border-color: #ff6b35; }
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { background: #f44336; }
-/* Channel form */
+/* Channel form — open state must scroll (Telegram pairing steps are tall) */
.channel-card { transition: all 0.2s; }
.channel-card.selected { border-color: #ff6b35; background: rgba(255,107,53,0.06); }
-.channel-form { padding-top: 0; margin-top: 10px; border-top: 1px solid #333; max-height: 0; overflow: hidden; transition: max-height 0.3s ease, padding 0.3s ease; }
-.channel-form.open { max-height: 300px; padding-top: 12px; }
+.channel-form { padding-top: 0; margin-top: 10px; border-top: 1px solid #333; max-height: 0; overflow: hidden; transition: max-height 0.35s ease, padding 0.35s ease; }
+.channel-form.open {
+ max-height: min(72vh, 560px);
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding-top: 12px;
+ padding-bottom: 4px;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-gutter: stable;
+}
+.channel-form.open::-webkit-scrollbar { width: 8px; }
+.channel-form.open::-webkit-scrollbar-thumb { background: #444; border-radius: 4px; }
+.channel-form.open::-webkit-scrollbar-thumb:hover { background: #555; }
+/* Telegram needs the full row when expanded */
+.channel-card.channel-expanded { grid-column: 1 / -1; }
.channel-form .form-group { margin-bottom: 10px; }
.channel-form label { font-size: 0.82em; color: #aaa; margin-bottom: 4px; }
.channel-form input { font-size: 0.88em; padding: 9px 12px; }
.channel-form .hint { font-size: 0.75em; margin-top: 4px; }
+.tg-steps code { background: #2a2a2a; padding: 1px 5px; border-radius: 4px; color: #ffb84d; font-size: 0.95em; }
/* Status bar */
.status-bar {
@@ -203,12 +264,11 @@ body[data-tier="standard"] [data-tier-min="expert"] { display: none !important;
-
-
-
-
-
-
+
+
+
+
+
@@ -268,6 +328,16 @@ body[data-tier="standard"] [data-tier-min="expert"] { display: none !important;
→ Get one from @BotFather
+
+
After saving, connect your Telegram account:
+
+ - Save the bot token above (click “Open dashboard” or save channels).
+ - On your phone, open Telegram, find your bot, and send
/start.
+ - Come back here — a button will appear. Click it once. No command line.
+
+
+
+
@@ -495,11 +565,16 @@ async function checkKey() {
const modelPicker = document.getElementById('modelPicker');
const modelChoice = document.getElementById('modelChoice');
+const keyActions = document.getElementById('keyActions');
function populateModels(provider, current) {
var options = (provider && provider.models) ? provider.models.slice() : [];
if (current && options.indexOf(current) === -1) options.unshift(current);
- if (options.length < 2) { modelPicker.style.display = 'none'; return; }
+ if (options.length < 2) {
+ modelPicker.style.display = 'none';
+ keyActions.classList.remove('has-model');
+ return;
+ }
modelChoice.innerHTML = '';
options.forEach(function (name) {
var opt = document.createElement('option');
@@ -509,6 +584,7 @@ function populateModels(provider, current) {
modelChoice.appendChild(opt);
});
modelPicker.style.display = 'block';
+ keyActions.classList.add('has-model');
}
// Check as they paste, but wait for typing to settle first.
@@ -581,6 +657,14 @@ async function saveConfig() {
var config = buildConfig(detected.id, detected.baseUrl, model, apiKey);
try {
+ var existingRes = await fetch('/api/config');
+ var existing = existingRes.ok ? await existingRes.json() : {};
+ delete existing.agent;
+ config = Object.assign(existing, config);
+ if (existing.channels) config.channels = existing.channels;
+ if (existing.commands) {
+ config.commands = Object.assign({}, existing.commands, config.commands || {});
+ }
var res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -601,11 +685,93 @@ function toggleChannel(card, type) {
if (isOpen) {
form.classList.remove('open');
card.classList.remove('selected');
+ card.classList.remove('channel-expanded');
+ if (type === 'telegram') stopTelegramPairingPoll();
} else {
form.classList.add('open');
card.classList.add('selected');
+ if (type === 'telegram') card.classList.add('channel-expanded');
var firstInput = form.querySelector('input');
if (firstInput) setTimeout(function() { firstInput.focus(); }, 100);
+ if (type === 'telegram') startTelegramPairingPoll();
+ }
+}
+
+// --- Telegram pairing (Control Panel, no CLI) ---
+var telegramPairingTimer = null;
+var telegramPendingCode = null;
+
+function stopTelegramPairingPoll() {
+ if (telegramPairingTimer) {
+ clearTimeout(telegramPairingTimer);
+ telegramPairingTimer = null;
+ }
+}
+
+function formatTelegramRequestLabel(req) {
+ if (req.username) return '@' + req.username;
+ if (req.firstName) return req.firstName;
+ return req.id || '';
+}
+
+async function pollTelegramPairing() {
+ var statusEl = document.getElementById('telegram-pairing-status');
+ var btn = document.getElementById('telegram-approve-btn');
+ if (!statusEl || !btn) return;
+
+ try {
+ var res = await fetch('/api/telegram/pairing');
+ var data = res.ok ? await res.json() : { requests: [] };
+ var requests = data.requests || [];
+
+ if (requests.length > 0) {
+ var req = requests[0];
+ telegramPendingCode = req.code;
+ var who = formatTelegramRequestLabel(req);
+ statusEl.innerHTML = '' + t('tg.pending', { who: who || req.id }) + '';
+ btn.style.display = 'block';
+ btn.disabled = false;
+ } else {
+ telegramPendingCode = null;
+ btn.style.display = 'none';
+ btn.disabled = true;
+ statusEl.textContent = t('tg.waiting');
+ }
+ } catch (e) {
+ statusEl.textContent = t('tg.poll_failed');
+ }
+
+ telegramPairingTimer = setTimeout(pollTelegramPairing, 2000);
+}
+
+function startTelegramPairingPoll() {
+ stopTelegramPairingPoll();
+ pollTelegramPairing();
+}
+
+async function approveTelegramPairing() {
+ if (!telegramPendingCode) return;
+ var btn = document.getElementById('telegram-approve-btn');
+ var statusEl = document.getElementById('telegram-pairing-status');
+ btn.disabled = true;
+ statusEl.textContent = t('tg.approving');
+
+ try {
+ var res = await fetch('/api/telegram/pairing/approve', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code: telegramPendingCode })
+ });
+ var data = await res.json();
+ if (!res.ok) throw new Error(data.error || String(res.status));
+ telegramPendingCode = null;
+ btn.style.display = 'none';
+ statusEl.innerHTML = '' + t('tg.approved') + '';
+ showToast(t('tg.approved_toast'));
+ } catch (e) {
+ statusEl.textContent = t('tg.approve_failed', { reason: e.message });
+ btn.disabled = false;
+ showToast(t('tg.approve_failed', { reason: e.message }), true);
}
}
@@ -748,6 +914,7 @@ async function saveChannelsAndOpen() {
body: JSON.stringify(existing)
});
showToast(t('ch.saved'));
+ if (channels.telegram) startTelegramPairingPoll();
} catch (e) {
showToast(t('ch.err_save'), true);
}
@@ -865,6 +1032,11 @@ async function loadConfig() {
if (modelChoice.options[i].value === model) { modelChoice.selectedIndex = i; break; }
}
}
+
+ if (cfg.channels && cfg.channels.telegram && cfg.channels.telegram.botToken) {
+ var tgInput = document.getElementById('ch-telegram-token');
+ if (tgInput) tgInput.value = cfg.channels.telegram.botToken;
+ }
} catch (e) { /* first run: nothing configured yet */ }
}
diff --git a/portable/config-server/server.js b/portable/config-server/server.js
index fc00b80..0ff050c 100644
--- a/portable/config-server/server.js
+++ b/portable/config-server/server.js
@@ -2,13 +2,144 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
+const { execFileSync } = require('child_process');
const { deflateSync } = require('zlib');
const crypto = require('crypto');
const PORT_RANGE_START = 18788;
const PORT_RANGE_END = 18798;
-const CONFIG_PATH = path.join(__dirname, '../data/.openclaw/openclaw.json');
-const RUNTIME_PATH = path.join(__dirname, '../data/.openclaw/runtime.json');
+const DATA_DIR = path.join(__dirname, '../data');
+const CONFIG_PATH = path.join(DATA_DIR, '.openclaw/openclaw.json');
+const RUNTIME_PATH = path.join(DATA_DIR, '.openclaw/runtime.json');
+const UCLAW_META_PATH = path.join(DATA_DIR, '.openclaw/uclaw-meta.json');
+
+function getStateDir() {
+ return process.env.OPENCLAW_STATE_DIR || path.join(DATA_DIR, '.openclaw');
+}
+
+function resolveNodeBinary() {
+ const runtimeRoot = path.join(__dirname, '../app/runtime');
+ if (process.platform === 'win32') {
+ return path.join(runtimeRoot, 'node-win-x64/node.exe');
+ }
+ if (process.platform === 'darwin') {
+ const dir = process.arch === 'arm64' ? 'node-mac-arm64' : 'node-mac-x64';
+ return path.join(runtimeRoot, dir, 'bin/node');
+ }
+ const dir = process.arch === 'arm64' ? 'node-linux-arm64' : 'node-linux-x64';
+ return path.join(runtimeRoot, dir, 'bin/node');
+}
+
+const OPENCLAW_ENTRY = path.join(__dirname, '../app/core/node_modules/openclaw/openclaw.mjs');
+
+function openclawEnv() {
+ return {
+ ...process.env,
+ OPENCLAW_HOME: DATA_DIR,
+ OPENCLAW_STATE_DIR: getStateDir(),
+ OPENCLAW_CONFIG_PATH: CONFIG_PATH,
+ };
+}
+
+function readConfigFile() {
+ if (!fs.existsSync(CONFIG_PATH)) return {};
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
+}
+
+function extractUclawMeta(config) {
+ if (!config.uclaw) return config;
+ const uclaw = config.uclaw;
+ delete config.uclaw;
+ try {
+ const existing = fs.existsSync(UCLAW_META_PATH)
+ ? JSON.parse(fs.readFileSync(UCLAW_META_PATH, 'utf8'))
+ : {};
+ fs.writeFileSync(UCLAW_META_PATH, `${JSON.stringify({ ...existing, ...uclaw }, null, 2)}\n`);
+ } catch (err) {
+ console.error('[uclaw-meta] write failed:', err.message);
+ }
+ return config;
+}
+
+function mergeConfigPreserve(existing, incoming) {
+ const next = { ...incoming };
+ if (existing.channels) {
+ next.channels = { ...existing.channels, ...(next.channels || {}) };
+ }
+ if (existing.commands?.ownerAllowFrom?.length) {
+ next.commands = {
+ ...(existing.commands || {}),
+ ...(next.commands || {}),
+ ownerAllowFrom: next.commands?.ownerAllowFrom?.length
+ ? next.commands.ownerAllowFrom
+ : existing.commands.ownerAllowFrom,
+ };
+ }
+ if (existing.plugins && !next.plugins) next.plugins = existing.plugins;
+ return next;
+}
+
+function writeConfigFile(config) {
+ const existing = readConfigFile();
+ let next = mergeConfigPreserve(existing, config);
+ next = extractUclawMeta(next);
+ delete next.agent;
+ const dir = path.dirname(CONFIG_PATH);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
+ return next;
+}
+
+function runOpenclawCli(args) {
+ const node = resolveNodeBinary();
+ if (!fs.existsSync(node) || !fs.existsSync(OPENCLAW_ENTRY)) {
+ throw new Error('OpenClaw runtime not found on this drive');
+ }
+ return execFileSync(node, [OPENCLAW_ENTRY, ...args], {
+ env: openclawEnv(),
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ }).trim();
+}
+
+function telegramPairingPath() {
+ return path.join(getStateDir(), 'credentials', 'telegram-pairing.json');
+}
+
+function readTelegramPairingRequests() {
+ const filePath = telegramPairingPath();
+ if (!fs.existsSync(filePath)) return [];
+ let parsed;
+ try {
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return [];
+ }
+ const requests = Array.isArray(parsed.requests) ? parsed.requests : [];
+ const now = Date.now();
+ return requests.filter((req) => {
+ const created = Date.parse(req.createdAt || '');
+ return Number.isFinite(created) && now - created < 3600 * 1000;
+ }).map((req) => ({
+ code: req.code,
+ id: req.id,
+ createdAt: req.createdAt,
+ username: req.meta?.username || req.meta?.name || '',
+ firstName: req.meta?.firstName || '',
+ }));
+}
+
+function approveTelegramPairing(code) {
+ const snapshot = readConfigFile();
+ const channels = snapshot.channels ? { ...snapshot.channels } : null;
+ const out = runOpenclawCli(['pairing', 'approve', 'telegram', code, '--notify']);
+ const after = readConfigFile();
+ if (channels) {
+ after.channels = { ...channels, ...(after.channels || {}) };
+ writeConfigFile(after);
+ }
+ return out;
+}
// ── WeChat Login State ──────────────────────────────────────────────────────
const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
@@ -493,7 +624,7 @@ const server = http.createServer((req, res) => {
const apiKey = String(parsed.apiKey || '').trim();
if (!apiKey) return reply({ ok: false, reason: 'key.err_empty' });
- const { detectProvider, classifyFailure } = await import('../lib/provider-detect.mjs');
+ const { detectProvider, classifyFailure, modelsToTry, discoverGoogleOpenAiModels } = await import('../lib/provider-detect.mjs');
const detected = detectProvider(apiKey);
const baseUrl = String(parsed.baseUrl || detected?.baseUrl || '').replace(/\/+$/, '');
const model = String(parsed.model || detected?.model || '');
@@ -505,7 +636,9 @@ const server = http.createServer((req, res) => {
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
- try {
+ const candidates = modelsToTry(detected, model);
+ let lastStatus = null;
+ const tryChat = async (tryModel) => {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
@@ -516,12 +649,39 @@ const server = http.createServer((req, res) => {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
- body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
+ body: JSON.stringify({ model: tryModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
});
- if (!response.ok) {
- return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
+ return response;
+ };
+ try {
+ for (const tryModel of candidates) {
+ const response = await tryChat(tryModel);
+ if (response.ok) {
+ return reply({ ok: true, provider: detected, baseUrl, model: tryModel, latencyMs: Date.now() - startedAt });
+ }
+ lastStatus = response.status;
+ // Only try the next Gemini model when this account cannot see the first one.
+ if (!(detected?.id === 'google' && response.status === 404)) {
+ return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
+ }
}
- return reply({ ok: true, provider: detected, baseUrl, model, latencyMs: Date.now() - startedAt });
+ if (detected?.id === 'google' && lastStatus === 404) {
+ const discovered = await discoverGoogleOpenAiModels(baseUrl, apiKey);
+ const extra = discovered.filter((id) => !candidates.includes(id));
+ const rank = (id) => (id.includes('flash') ? 0 : 10) + (id.includes('2.0') ? 0 : 5) + (id.includes('lite') ? 1 : 0);
+ extra.sort((a, b) => rank(a) - rank(b));
+ for (const tryModel of extra) {
+ const response = await tryChat(tryModel);
+ if (response.ok) {
+ return reply({ ok: true, provider: detected, baseUrl, model: tryModel, latencyMs: Date.now() - startedAt });
+ }
+ lastStatus = response.status;
+ if (response.status !== 404) {
+ return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
+ }
+ }
+ }
+ return reply({ ok: false, provider: detected, status: lastStatus, reason: classifyFailure({ status: lastStatus }) });
} catch (err) {
return reply({ ok: false, provider: detected, reason: classifyFailure({ code: err?.cause?.code || err?.code || err?.name }) });
} finally {
@@ -624,18 +784,49 @@ const server = http.createServer((req, res) => {
return;
}
+ // API: Telegram pairing — list pending DM approvals (no CLI for the user)
+ if (req.url === '/api/telegram/pairing' && req.method === 'GET') {
+ try {
+ const requests = readTelegramPairingRequests();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ requests }));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: err.message }));
+ }
+ return;
+ }
+
+ // API: Telegram pairing — one-click approve from the Control Panel
+ if (req.url === '/api/telegram/pairing/approve' && req.method === 'POST') {
+ let body = '';
+ req.on('data', chunk => { body += chunk; });
+ req.on('end', () => {
+ try {
+ const { code } = JSON.parse(body || '{}');
+ if (!code || typeof code !== 'string') {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Missing pairing code' }));
+ return;
+ }
+ const message = approveTelegramPairing(code.trim().toUpperCase());
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, message }));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: err.message }));
+ }
+ });
+ return;
+ }
+
// API: Save config
if (req.url === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
- const config = JSON.parse(body);
- // 清除旧版废弃键,防止 OpenClaw 报 "agent.* was moved" 错误
- delete config.agent;
- const dir = path.dirname(CONFIG_PATH);
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
+ writeConfigFile(JSON.parse(body));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
diff --git a/portable/lib/i18n.mjs b/portable/lib/i18n.mjs
index b07081c..f59b22b 100644
--- a/portable/lib/i18n.mjs
+++ b/portable/lib/i18n.mjs
@@ -46,10 +46,22 @@ function localeFromSystem() {
}
}
+function localeFromMeta(configPath) {
+ if (!configPath) return null;
+ try {
+ const metaPath = join(dirname(configPath), 'uclaw-meta.json');
+ if (!existsSync(metaPath)) return null;
+ return normalise(JSON.parse(readFileSync(metaPath, 'utf8'))?.locale);
+ } catch {
+ return null;
+ }
+}
+
export function resolveLocale({ override, configPath } = {}) {
return (
normalise(override) ||
localeFromConfig(configPath) ||
+ localeFromMeta(configPath) ||
localeFromSystem() ||
FALLBACK
);
diff --git a/portable/lib/i18n/messages.js b/portable/lib/i18n/messages.js
index 6281f17..0790907 100644
--- a/portable/lib/i18n/messages.js
+++ b/portable/lib/i18n/messages.js
@@ -176,6 +176,18 @@ window.UCLAW_I18N_MESSAGES = {
"ch.wechat_start": "Click this card to show the QR code",
"ch.saved": "Chat app settings saved.",
"ch.err_save": "Could not save the chat app settings. Check the drive is still plugged in.",
+ "tg.steps_title": "After saving, connect your Telegram account:",
+ "tg.step1": "Save the bot token above (click “Open dashboard” or save channels).",
+ "tg.step2": "On your phone, open Telegram, find your bot, and send /start.",
+ "tg.step3": "Come back here — a button will appear. Click it once. No command line.",
+ "tg.waiting": "Waiting for you to send /start to your bot on Telegram…",
+ "tg.pending": "Someone wants to connect: {who}. Click below to allow.",
+ "tg.approve": "Allow this Telegram account",
+ "tg.approving": "Approving…",
+ "tg.approved": "✅ Telegram connected — go chat in Telegram.",
+ "tg.approved_toast": "Telegram account approved.",
+ "tg.approve_failed": "Could not approve: {reason}",
+ "tg.poll_failed": "Could not check pairing status. Is U-Claw still running?",
"info.layout_title": "What is on the drive",
"info.layout_desc": "How the folders are organised",
"info.skills": "Skills",
@@ -459,6 +471,18 @@ window.UCLAW_I18N_MESSAGES = {
"ch.wechat_start": "点这张卡片开始扫码",
"ch.saved": "聊天软件设置已保存。",
"ch.err_save": "聊天软件设置没能保存。确认 U 盘还插着。",
+ "tg.steps_title": "保存 Bot Token 后,连接你的 Telegram:",
+ "tg.step1": "先保存上面的 Bot Token(点「打开对话界面」或保存聊天软件设置)。",
+ "tg.step2": "在手机上打开 Telegram,找到你的机器人,发送 /start。",
+ "tg.step3": "回到这个页面 —— 会出现一个按钮,点一下就行,不用敲命令。",
+ "tg.waiting": "等你去 Telegram 给机器人发 /start…",
+ "tg.pending": "有人要连接:{who}。点下面按钮允许。",
+ "tg.approve": "允许这个 Telegram 账号",
+ "tg.approving": "正在批准…",
+ "tg.approved": "✅ Telegram 已连通 —— 去 Telegram 里聊吧。",
+ "tg.approved_toast": "Telegram 账号已批准。",
+ "tg.approve_failed": "批准失败:{reason}",
+ "tg.poll_failed": "无法检查配对状态。U-Claw 还在运行吗?",
"info.layout_title": "U 盘里有什么",
"info.layout_desc": "文件夹是怎么组织的",
"info.skills": "技能",
diff --git a/portable/lib/install-skills.mjs b/portable/lib/install-skills.mjs
new file mode 100644
index 0000000..6c3b7a5
--- /dev/null
+++ b/portable/lib/install-skills.mjs
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+// Installs skills from skills/manifest.json into a target directory.
+//
+// This is the only place skill content is handled. install.sh and install.ps1
+// call it and contain no skill text of their own, which is what keeps the two
+// platforms provably identical (see tests/skills-manifest.test.mjs) and keeps
+// non-ASCII bytes out of .bat launchers (see tests/windows-launchers.test.mjs).
+//
+// node lib/install-skills.mjs --target
[--locale en] [--persona general]
+// [--source ] [--ref ]
+// [--list] [--dry-run] [--json]
+
+import { mkdirSync, readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+// Where to fetch from lives in origin.json so a move to our own host is one
+// edit. Falling back to literals keeps this working when the file is absent —
+// the remote installers download this script on its own, without the repo.
+const ORIGIN = (() => {
+ for (const dir of [join(scriptDirOf(), '..'), scriptDirOf()]) {
+ try { return JSON.parse(readFileSync(join(dir, 'origin.json'), 'utf8')); } catch { /* try next */ }
+ }
+ return null;
+})();
+
+// A template rather than a base URL: Gitea and GitHub lay raw paths out
+// differently, and hardcoding either shape makes the other impossible.
+const RAW_TEMPLATE = ORIGIN?.urls?.rawTemplate
+ ?? 'https://gitea.fanghe.it.com/zhenghy/u-claw/raw/branch/{ref}/{path}';
+const DEFAULT_REF = ORIGIN?.repo?.ref ?? 'main';
+function scriptDirOf() { return dirname(fileURLToPath(import.meta.url)); }
+const scriptDir = scriptDirOf();
+
+function parseArgs(argv) {
+ const opts = { locale: 'en', personas: [], list: false, dryRun: false, json: false, ref: DEFAULT_REF };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ const next = () => {
+ const value = argv[++i];
+ if (value === undefined) fail(`${arg} needs a value`);
+ return value;
+ };
+ switch (arg) {
+ case '--target': opts.target = next(); break;
+ case '--source': opts.source = next(); break;
+ case '--locale': opts.locale = next(); break;
+ case '--persona': opts.personas.push(next()); break;
+ case '--ref': opts.ref = next(); break;
+ case '--list': opts.list = true; break;
+ case '--dry-run': opts.dryRun = true; break;
+ case '--json': opts.json = true; break;
+ case '--help': case '-h': usage(); process.exit(0); break;
+ default: fail(`unknown argument: ${arg}`);
+ }
+ }
+ return opts;
+}
+
+function usage() {
+ process.stdout.write(
+ 'Usage: install-skills.mjs --target [--locale en] [--persona ]...\n' +
+ ' [--source ] [--ref ] [--list] [--dry-run] [--json]\n'
+ );
+}
+
+function fail(message) {
+ process.stderr.write(`install-skills: ${message}\n`);
+ process.exit(1);
+}
+
+// Content lives in the repo checkout or on the USB. The one-line installers have
+// neither, so they fall through to fetching from GitHub at a pinned ref.
+function resolveLocalSource(explicit) {
+ const candidates = [explicit, join(scriptDir, '..', 'skills'), join(scriptDir, 'skills')];
+ for (const dir of candidates) {
+ if (dir && existsSync(join(dir, 'manifest.json'))) return resolve(dir);
+ }
+ return null;
+}
+
+async function fetchText(url) {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText} for ${url}`);
+ return response.text();
+}
+
+function rawUrl(ref, path) {
+ return RAW_TEMPLATE.replace('{ref}', ref).replace('{path}', `skills/${path}`);
+}
+
+function selectSkills(manifest, { locale, personas }) {
+ return manifest.skills
+ .filter((skill) => skill.status === 'shipping')
+ .filter((skill) => (skill.locales ?? []).includes(locale))
+ // No --persona means install everything available for the locale.
+ .filter((skill) => personas.length === 0 || personas.some((p) => (skill.personas ?? []).includes(p)))
+ .sort((a, b) => a.id.localeCompare(b.id));
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ if (!opts.target && !opts.list) fail('--target is required (or use --list)');
+
+ const localSource = resolveLocalSource(opts.source);
+ const remote = localSource === null;
+
+ let manifest;
+ try {
+ manifest = JSON.parse(
+ remote
+ ? await fetchText(rawUrl(opts.ref, 'manifest.json'))
+ : readFileSync(join(localSource, 'manifest.json'), 'utf8')
+ );
+ } catch (error) {
+ fail(`could not read the skill manifest: ${error.message}`);
+ }
+
+ const selected = selectSkills(manifest, opts);
+
+ if (opts.list) {
+ process.stdout.write(
+ opts.json
+ ? `${JSON.stringify(selected.map((s) => s.id))}\n`
+ : `${selected.map((s) => s.id).join('\n')}\n`
+ );
+ return;
+ }
+
+ let installed = 0;
+ let skipped = 0;
+ const failures = [];
+
+ for (const skill of selected) {
+ const destDir = join(opts.target, skill.id);
+ const destFile = join(destDir, 'SKILL.md');
+
+ if (existsSync(destFile)) { skipped++; continue; }
+ if (opts.dryRun) { installed++; continue; }
+
+ try {
+ mkdirSync(destDir, { recursive: true });
+ if (remote) {
+ writeFileSync(destFile, await fetchText(rawUrl(opts.ref, `${opts.locale}/${skill.id}/SKILL.md`)), 'utf8');
+ } else {
+ const src = join(localSource, opts.locale, skill.id, 'SKILL.md');
+ if (!existsSync(src)) throw new Error(`missing ${src}`);
+ copyFileSync(src, destFile);
+ }
+ installed++;
+ } catch (error) {
+ // One bad skill should not abort the install — report at the end instead.
+ failures.push(`${skill.id}: ${error.message}`);
+ }
+ }
+
+ if (opts.json) {
+ process.stdout.write(`${JSON.stringify({ installed, skipped, failed: failures })}\n`);
+ } else {
+ process.stdout.write(`skills installed: ${installed}, already present: ${skipped}\n`);
+ for (const failure of failures) process.stderr.write(` failed: ${failure}\n`);
+ }
+
+ if (failures.length > 0 && installed === 0) process.exit(1);
+}
+
+main().catch((error) => fail(error.message));
diff --git a/portable/lib/messages/en.json b/portable/lib/messages/en.json
index c4413a9..0bef58a 100644
--- a/portable/lib/messages/en.json
+++ b/portable/lib/messages/en.json
@@ -169,6 +169,18 @@
"ch.wechat_start": "Click this card to show the QR code",
"ch.saved": "Chat app settings saved.",
"ch.err_save": "Could not save the chat app settings. Check the drive is still plugged in.",
+ "tg.steps_title": "After saving, connect your Telegram account:",
+ "tg.step1": "Save the bot token above (click “Open dashboard” or save channels).",
+ "tg.step2": "On your phone, open Telegram, find your bot, and send /start.",
+ "tg.step3": "Come back here — a button will appear. Click it once. No command line.",
+ "tg.waiting": "Waiting for you to send /start to your bot on Telegram…",
+ "tg.pending": "Someone wants to connect: {who}. Click below to allow.",
+ "tg.approve": "Allow this Telegram account",
+ "tg.approving": "Approving…",
+ "tg.approved": "✅ Telegram connected — go chat in Telegram.",
+ "tg.approved_toast": "Telegram account approved.",
+ "tg.approve_failed": "Could not approve: {reason}",
+ "tg.poll_failed": "Could not check pairing status. Is U-Claw still running?",
"info.layout_title": "What is on the drive",
"info.layout_desc": "How the folders are organised",
"info.skills": "Skills",
diff --git a/portable/lib/messages/zh-CN.json b/portable/lib/messages/zh-CN.json
index e8e1d36..8ec038c 100644
--- a/portable/lib/messages/zh-CN.json
+++ b/portable/lib/messages/zh-CN.json
@@ -169,6 +169,18 @@
"ch.wechat_start": "点这张卡片开始扫码",
"ch.saved": "聊天软件设置已保存。",
"ch.err_save": "聊天软件设置没能保存。确认 U 盘还插着。",
+ "tg.steps_title": "保存 Bot Token 后,连接你的 Telegram:",
+ "tg.step1": "先保存上面的 Bot Token(点「打开对话界面」或保存聊天软件设置)。",
+ "tg.step2": "在手机上打开 Telegram,找到你的机器人,发送 /start。",
+ "tg.step3": "回到这个页面 —— 会出现一个按钮,点一下就行,不用敲命令。",
+ "tg.waiting": "等你去 Telegram 给机器人发 /start…",
+ "tg.pending": "有人要连接:{who}。点下面按钮允许。",
+ "tg.approve": "允许这个 Telegram 账号",
+ "tg.approving": "正在批准…",
+ "tg.approved": "✅ Telegram 已连通 —— 去 Telegram 里聊吧。",
+ "tg.approved_toast": "Telegram 账号已批准。",
+ "tg.approve_failed": "批准失败:{reason}",
+ "tg.poll_failed": "无法检查配对状态。U-Claw 还在运行吗?",
"info.layout_title": "U 盘里有什么",
"info.layout_desc": "文件夹是怎么组织的",
"info.skills": "技能",
diff --git a/portable/lib/provider-detect.mjs b/portable/lib/provider-detect.mjs
index de656e6..9bebff2 100644
--- a/portable/lib/provider-detect.mjs
+++ b/portable/lib/provider-detect.mjs
@@ -27,10 +27,12 @@ const PROVIDERS = [
{
id: 'google',
label: 'Google Gemini',
- prefixes: ['AIza'],
+ // Google AI Studio now issues Auth keys (AQ.Ab…) alongside legacy Standard keys (AIzaSy…).
+ prefixes: ['AQ.', 'AIza'],
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
- model: 'gemini-2.5-flash',
- models: ['gemini-2.5-flash', 'gemini-2.5-pro'],
+ // New AI Studio accounts often cannot use 2.5-flash yet (404). Try 2.0 first.
+ model: 'gemini-2.0-flash',
+ models: ['gemini-2.0-flash', 'gemini-flash-latest', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'],
},
{
id: 'groq',
@@ -75,7 +77,7 @@ export function detectProvider(rawKey) {
export function classifyFailure({ status, code } = {}) {
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') return 'key.err_offline';
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ABORT_ERR') return 'key.err_timeout';
- if (status === 401 || status === 403) return 'key.err_rejected';
+ if (status === 400 || status === 401 || status === 403) return 'key.err_rejected';
if (status === 429) return 'key.err_quota';
if (status === 404) return 'key.err_model';
if (typeof status === 'number' && status >= 500) return 'key.err_provider_down';
@@ -85,3 +87,32 @@ export function classifyFailure({ status, code } = {}) {
export function listProviders() {
return PROVIDERS.map(({ id, label, baseUrl, model, models }) => ({ id, label, baseUrl, model, models }));
}
+
+// Google rotates model availability per account. When the first choice 404s, try the rest.
+export function modelsToTry(provider, requestedModel) {
+ const primary = String(requestedModel || provider?.model || '').trim();
+ if (provider?.id === 'google') {
+ return [...new Set([primary, provider.model, ...(provider.models || [])].filter(Boolean))];
+ }
+ return primary ? [primary] : [];
+}
+
+// AQ auth keys only work on the OpenAI-compatible surface. Ask Google which models
+// this key can actually see instead of guessing from a static list.
+export async function discoverGoogleOpenAiModels(baseUrl, apiKey) {
+ const root = String(baseUrl || '').replace(/\/+$/, '');
+ if (!root || !apiKey) return [];
+ try {
+ const res = await fetch(`${root}/models`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ if (!res.ok) return [];
+ const body = await res.json();
+ const rows = body.data || body.models || [];
+ return rows
+ .map((m) => String(m.id || m.name || '').replace(/^models\//, ''))
+ .filter(Boolean);
+ } catch {
+ return [];
+ }
+}
diff --git a/portable/lib/start.mjs b/portable/lib/start.mjs
index a13b426..52d58e1 100644
--- a/portable/lib/start.mjs
+++ b/portable/lib/start.mjs
@@ -256,10 +256,30 @@ function applyNoProxy(t, nodeBin) {
// the language follow the machine instead of the drive, which is exactly the
// behaviour we do not want. A generated