From dd8e0f55c3e58357fc2dd4f5124d08ba6b78dabd Mon Sep 17 00:00:00 2001 From: zheng <18760530726@163.com> Date: Tue, 18 Aug 2026 18:45:03 +0800 Subject: [PATCH] fix(portable): Gemini keys, Telegram pairing UI, and config durability Improve first-run and channel setup for non-technical users: detect newer Gemini key formats, pin Node 22.22.3, add Control Panel Telegram approve flow, and keep channels/models when config is rewritten. Persist uclaw wizard state via uclaw-meta.json so restarts skip language/persona prompts. Co-authored-by: Cursor --- NODE_VERSION | 2 +- portable/NODE_VERSION | 1 + portable/config-server/public/index.html | 194 +++++++++- portable/config-server/server.js | 219 ++++++++++- portable/lib/i18n.mjs | 12 + portable/lib/i18n/messages.js | 24 ++ portable/lib/install-skills.mjs | 167 +++++++++ portable/lib/messages/en.json | 12 + portable/lib/messages/zh-CN.json | 12 + portable/lib/provider-detect.mjs | 39 +- portable/lib/start.mjs | 29 +- portable/skills/README.md | 87 +++++ portable/skills/en/claude-helper/SKILL.md | 77 ++++ portable/skills/en/email-campaign/SKILL.md | 77 ++++ portable/skills/en/excel-helper/SKILL.md | 101 +++++ portable/skills/en/image-compress/SKILL.md | 52 +++ portable/skills/en/linkedin-post/SKILL.md | 71 ++++ portable/skills/en/medium-writer/SKILL.md | 76 ++++ portable/skills/en/meeting-notes/SKILL.md | 72 ++++ portable/skills/en/pdf-toolkit/SKILL.md | 65 ++++ portable/skills/en/ppt-designer/SKILL.md | 121 ++++++ portable/skills/en/qrcode-maker/SKILL.md | 55 +++ portable/skills/en/sea-translate/SKILL.md | 68 ++++ portable/skills/en/sg-transport/SKILL.md | 82 +++++ portable/skills/en/sg-weather/SKILL.md | 80 ++++ portable/skills/en/tiktok-script/SKILL.md | 68 ++++ portable/skills/en/uclaw-help/SKILL.md | 117 ++++++ portable/skills/en/web-search/SKILL.md | 53 +++ portable/skills/en/web-to-markdown/SKILL.md | 57 +++ portable/skills/en/word-writer/SKILL.md | 76 ++++ portable/skills/en/x-poster/SKILL.md | 63 ++++ portable/skills/en/youtube-script/SKILL.md | 69 ++++ portable/skills/manifest.json | 385 ++++++++++++++++++++ tests/i18n-pages.test.mjs | 10 +- tests/wechat-login-redirect.test.mjs | 14 + 35 files changed, 2673 insertions(+), 34 deletions(-) create mode 100644 portable/NODE_VERSION create mode 100644 portable/lib/install-skills.mjs create mode 100644 portable/skills/README.md create mode 100644 portable/skills/en/claude-helper/SKILL.md create mode 100644 portable/skills/en/email-campaign/SKILL.md create mode 100644 portable/skills/en/excel-helper/SKILL.md create mode 100644 portable/skills/en/image-compress/SKILL.md create mode 100644 portable/skills/en/linkedin-post/SKILL.md create mode 100644 portable/skills/en/medium-writer/SKILL.md create mode 100644 portable/skills/en/meeting-notes/SKILL.md create mode 100644 portable/skills/en/pdf-toolkit/SKILL.md create mode 100644 portable/skills/en/ppt-designer/SKILL.md create mode 100644 portable/skills/en/qrcode-maker/SKILL.md create mode 100644 portable/skills/en/sea-translate/SKILL.md create mode 100644 portable/skills/en/sg-transport/SKILL.md create mode 100644 portable/skills/en/sg-weather/SKILL.md create mode 100644 portable/skills/en/tiktok-script/SKILL.md create mode 100644 portable/skills/en/uclaw-help/SKILL.md create mode 100644 portable/skills/en/web-search/SKILL.md create mode 100644 portable/skills/en/web-to-markdown/SKILL.md create mode 100644 portable/skills/en/word-writer/SKILL.md create mode 100644 portable/skills/en/x-poster/SKILL.md create mode 100644 portable/skills/en/youtube-script/SKILL.md create mode 100644 portable/skills/manifest.json 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:

+
    +
  1. Save the bot token above (click “Open dashboard” or save channels).
  2. +
  3. On your phone, open Telegram, find your bot, and send /start.
  4. +
  5. Come back here — a button will appear. Click it once. No command line.
  6. +
+
+ +
@@ -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